简介:这是一份面向 Android 中高级开发者与移动 UI 学习者的智能锁屏实战源码包,围绕锁屏界面的视觉设计、交互逻辑、安全机制与性能优化展开,适合用于课程设计、组件化练习与锁屏功能二次开发。压缩包共 57 个文件、约 722KB,以 6 个 Java 源文件与 18 个 class 编译产物为主体,配合 11 个 xml 布局与配置、9 个 png 图标切图,另有 jar 依赖库、properties 工程配置及 apk、dex 可运行产物,构成一个可直接导入 Eclipse 的完整工程。工程涵盖壁纸服务、通知管理、指纹与图案解锁、快捷功能入口、主题与字体自定义、动画与手势识别等模块,并附 proguard 混淆配置与 JavaApk 源码说明文档,便于读者对照阅读解锁流程、通知隐私过滤与内存占用控制的实现思路。目前已有 249 人学习,适合希望从零拆解一套轻量锁屏应用结构、积累系统服务集成经验的开发者参考。
1. 锁屏不是一张静态壁纸:Android 智能UI锁屏在解决谁的什么问题
Android 智能UI锁屏这个词,听起来像给锁屏换一套皮肤,真正落到工程里却是两件事拼在一起:让一个界面稳定地出现在系统锁屏之上,以及让这个界面的内容随场景变化。前者跟 Keyguard、前台服务、全屏通知、后台启动限制打交道,后者跟信号采集、打分决策、版面降级打交道。做这两件事的人,通常不是想炫技,而是想让用户在不点亮主界面的那三秒里,直接看到今天最该看的两三条信息——通勤时的路况、充电时的进度、深夜时的勿扰入口。适合已经能写 Activity、懂一点 Compose、被“锁屏上浮层一闪而过”折磨过的 Android 开发者,也适合想评估这条技术路线值不值得投人的技术负责人。
2. 锁屏界面的三条落地路径:系统锁屏扩展、全屏 Activity 与锁屏小组件怎么选
2.1 系统锁屏归谁管:为什么普通应用贴不上去
先把边界说清楚,后面很多坑就不用踩了。Android 的锁屏界面(Keyguard)是由系统 UI 进程持有并绘制的窗口,普通三方应用的窗口默认在 Keyguard 之下,用户点亮屏幕看到的第一层是系统锁屏,你的 Activity 即使已经onResume,也可能被压在下面。KeyguardManager.isKeyguardLocked()读到的就是这层状态,它只告诉你“锁着还是没锁”,不给你任何绘制权限。
所以“自定义锁屏”在三方应用里从来不是“替换系统锁屏”,而是“在系统锁屏之上申请一个可见窗口”。这个可见窗口有两种形态:一种是被授予了showWhenLocked的 Activity,它在锁屏状态下仍然可见;另一种是通过通知的全屏 Intent 在锁屏上弹出。两条路都要过系统的一道审核——你要证明这个界面属于用户此刻真的需要的场景,而不是广告。
我一般会先让团队里所有人跑一遍判断:如果去掉亮屏那一瞬间的展示,你的功能是否还能成立?如果不成立,说明你确实需要锁屏层;如果只是“想多一个曝光入口”,那锁屏是投入产出比最差的位置,因为后面每一个适配坑都得付一次成本。
2.2 三条落地路径的权限门槛与适配成本
落地路径我见过的主要三种,各自的能力上限差别很大,选错了后面全是返工。
| 路径 | 可控范围 | 权限与门槛 | 适配成本 | 适合的场景 |
|---|---|---|---|---|
锁屏 Activity(showWhenLocked) | 全屏界面、任意 Compose 布局、动效、手势 | 需要用户开启通知;部分版本对后台启动有限制,需前台服务或全屏通知拉起 | 中高,需处理分辨率、深色、内存、生命周期 | 深度定制的资讯卡、日程卡、充电面板 |
| 通知 + 全屏 Intent | 一个由系统渲染的通知和一次全屏拉起 | USE_FULL_SCREEN_INTENT声明,且高版本对非通话/闹钟类收紧 | 中,主要受系统放行策略影响 | 强时效提醒、一次性展示 |
| 锁屏小组件 / RemoteViews | 宿主容器内的有限控件,尺寸由宿主给 | 依赖宿主支持,控件白名单受限 | 低但天花板明显 | 轻量信息块、简单状态展示 |
选型上我的建议很直接:要“智能排版”就老老实实走锁屏 Activity,因为只有这条路的布局能力足以支撑动态卡片编排;RemoteViews 那套只适合做“固定位置放一个数字”。至于全屏 Intent,把它当成拉起锁屏 Activity 的一个触发器,而不是界面本身——这样即使某天系统收紧了放行策略,你的界面还在,只是拉起方式要换。
顺带说一句,全屏通知的分类不要乱填。为了更容易被放行就把分类伪造成通话或闹钟,是典型的短期省事长期翻车,应用商店审核和用户投诉两头都躲不过。
2.3 跑通一个最小可用的锁屏界面
先把最小闭环跑起来,别一上来就写智能逻辑。第一步是声明宿主 Activity,注意主题必须是半透明或独立主题,否则解锁瞬间会闪一下黑屏。
<!-- AndroidManifest.xml:锁屏宿主 Activity 的最小声明 --> <activity android:name=".lockscreen.LockHostActivity" android:exported="false" android:launchMode="singleInstance" android:excludeFromRecents="true" android:showWhenLocked="true" android:turnScreenOn="true" android:theme="@style/Theme.LockHost" /> <!-- Theme.LockHost 建议继承无 ActionBar 的深色主题, 窗口背景设为半透明,避免解锁时出现纯黑闪帧 -->第二步,在代码里做版本回落。showWhenLocked这个 Activity 方法从 Android 8.1(API 27)才有,低版本仍然要用窗口 flag,这段分支我建议直接抄,不要每次重写。
class LockHostActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // API 27 起优先使用 Activity 级方法,系统能正确感知窗口属性 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { setShowWhenLocked(true) // 锁屏状态下保持可见 setTurnScreenOn(true) // 亮屏时直接进入本界面 } else { window.addFlags( WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON ) } // 关键取舍:不主动 requestDismissKeyguard。 // 用户可能只想瞥一眼,强行解锁会打断后续操作,也容易触发安全策略。 setContent { LockRoot() } } // 解锁后及时清理可见性标记,避免界面残留在主屏之上 override fun onDestroy() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { setShowWhenLocked(false) setTurnScreenOn(false) } super.onDestroy() } }这段代码里有两个参数值得单独说。setShowWhenLocked(true)决定的是“锁屏时窗口是否可见”,它不改锁屏状态,用户依然需要指纹或密码才能进主界面;setTurnScreenOn(true)决定的是“亮屏时是否直接显示本 Activity”,它只对点亮屏幕这个动作生效,不影响用户主动返回。两个都开,才是“亮屏即见”的效果。低版本那两个 flag 名字很像但语义一致,唯一区别是清理时要用clearFlags。
第三步,用全屏 Intent 拉起。这里的参数更微妙,setFullScreenIntent(pi, true)的第二个参数是“是否在锁屏时也显示 heads-up”,设成true才会在锁屏上直接弹出。
private fun fireLockSurface() { val intent = Intent(this, LockHostActivity::class.java).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) } val pi = PendingIntent.getActivity( this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) val notification = NotificationCompat.Builder(this, CHANNEL_ID) .setSmallIcon(R.drawable.ic_lock_badge) .setContentTitle("今日概览") .setPriority(NotificationCompat.PRIORITY_HIGH) // 低于 HIGH 不会触发全屏 .setCategory(NotificationCompat.CATEGORY_REMINDER) // 按真实业务分类,勿伪造 .setFullScreenIntent(pi, true) .setAutoCancel(true) .build() NotificationManagerCompat.from(this).notify(NOTIFY_ID, notification) }PRIORITY_HIGH及以上才会进入全屏放行队列,PRIORITY_DEFAULT只会静默挂在通知栏;NOTIFY_ID要固定,否则每次触发都新建一条通知,用户会看到满屏同款。还有一点:全屏 Intent 只是“请求拉起”,系统有权降级成普通通知,所以你的界面必须能在“被降级为通知”时也说得过去,不能把全部信息塞在一个只有锁屏才渲染的布局里。
3. 场景信号怎么来:把时间、充电、使用频次变成可决策的特征向量
3.1 哪些信号真的可用:权限、精度与刷新成本
“智能”这件事,很多人第一反应是接一个模型。锁屏这个位置恰恰相反:可用信号少、刷新成本高、容错空间小,规则打分往往比模型更稳,因为你能对用户解释“为什么今天给你看这个”。
我按获取成本把信号分成三档。零成本档:本地时间、星期、充电状态、屏幕亮灭、解锁事件。这些不需要任何权限,ACTION_USER_PRESENT动态注册就能拿到(注意 8.0 之后静态注册失效)。中成本档:应用使用统计,需要PACKAGE_USAGE_STATS特殊权限,用户得去设置页手动开,授权率通常不高,要做好“没授权就降级”的设计。高成本档:位置、日历、健康数据,权限弹窗重、隐私敏感,锁屏这种高频场景我不建议碰,收益覆盖不了打扰成本。
充电状态是我最推荐的一个信号,因为它的语义特别干净:插着电通常意味着用户在桌面或床边,停留时间长,适合展示信息密度高的卡片;没插电则要克制,别在电量 15% 的时候给他推一条耗电的视频卡片。
// 用粘性广播读一次当前电量与充电状态,不需要注册常驻监听 val filter = IntentFilter(Intent.ACTION_BATTERY_CHANGED) val status = registerReceiver(null, filter) val isCharging = status?.getIntExtra(BatteryManager.EXTRA_STATUS, -1) ?.let { it == BatteryManager.BATTERY_STATUS_CHARGING || it == BatteryManager.BATTERY_STATUS_FULL } ?: false val level = status?.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) ?: -1传null作为 receiver 拿的是粘性广播,读一次就走,不会留下常驻监听,这在锁屏启动路径上很重要——多一个常驻监听就多一份被系统判定为后台耗电的风险。
3.2 把场景离散成特征向量的写法
连续值直接进决策函数是灾难,因为同样的“18:03”和“18:47”会算出完全不同的结果,导致版面抖动。我一般先做离散化,把一天切成几个语义时段,把频次切成几档,把应用类别归成几类。
/** 锁屏决策用的上下文快照,全部字段都是离散值 */ data class LockContext( val timeBucket: TimeBucket, // 深夜 / 早间 / 通勤 / 工作 / 晚间 val weekdayType: WeekdayType, // 工作日 / 休息日 val charging: Boolean, val batteryLow: Boolean, // 电量 < 20% val unlockDensity: Int, // 近 1 小时解锁次数,分 0/1-3/4+ 三档 val topCategory: AppCategory // 社交 / 内容 / 工具 / 无授权 ) enum class TimeBucket { NIGHT, MORNING, COMMUTE, WORK, EVENING } enum class WeekdayType { WORKDAY, WEEKEND } enum class AppCategory { SOCIAL, CONTENT, TOOL, UNKNOWN } fun buildContext(now: LocalDateTime, unlockCount: Int, usage: UsageSnapshot): LockContext { val bucket = when (now.hour) { in 0..5 -> TimeBucket.NIGHT in 6..8 -> TimeBucket.MORNING in 9..11, in 17..19 -> TimeBucket.COMMUTE in 12..16 -> TimeBucket.WORK else -> TimeBucket.EVENING } // 解锁密度做三档归并,避免 2 次和 3 次被当成两种不同场景 val density = when { unlockCount == 0 -> 0 unlockCount <= 3 -> 1 else -> 2 } return LockContext( timeBucket = bucket, weekdayType = if (now.dayOfWeek.value >= 6) WeekdayType.WEEKEND else WeekdayType.WORKDAY, charging = usage.charging, batteryLow = usage.level in 0..20, unlockDensity = density, topCategory = usage.topCategory ) }TimeBucket的边界不是随便切的,它对齐的是用户行为切换点而不是整点:早间和通勤之间差着一个“出门”动作,晚间和工作之间差着一个“下班”动作。unlockDensity三档归并的意义在于抗抖动,实测里 1 次和 3 次解锁对应的行为几乎没有区别,但如果你把它当连续值,同一个用户在同一时段会看到两套版面,这种“玄学跳动”是最容易被投诉的问题。
3.3 决策引擎:规则打分比模型更适合锁屏
打分函数的写法很朴素:每张候选卡片对当前上下文有一个权重,取前 N 张,同分按固定优先级排序保证稳定。关键不是算法多花哨,而是每一个权重都能说清楚来源。
| 卡片类型 | 深夜 | 早间 | 通勤 | 工作 | 充电中 | 电量低 |
|---|---|---|---|---|---|---|
| 日程提醒 | 0.2 | 0.9 | 0.7 | 0.8 | 0.6 | 0.4 |
| 通勤路况 | 0.0 | 0.6 | 1.0 | 0.2 | 0.3 | 0.1 |
| 充电进度 | 0.5 | 0.3 | 0.1 | 0.2 | 1.0 | 0.3 |
| 内容推荐 | 0.1 | 0.2 | 0.5 | 0.1 | 0.4 | 0.0 |
| 勿扰入口 | 1.0 | 0.2 | 0.1 | 0.3 | 0.2 | 0.2 |
这张表是起点不是终点,真实项目里我会让它可配置下发,方便按人群调。注意“电量低”那一列,内容推荐直接给 0.0——不是它不重要,是这个时候推内容会让用户觉得你没分寸感。
data class CardSpec(val id: String, val type: CardType, val basePriority: Int) fun rankCards(ctx: LockContext, cards: List<CardSpec>, limit: Int): List<CardSpec> = cards.asSequence() .map { it to weightOf(it.type, ctx) } .filter { it.second > 0.0 } // 权重为 0 直接淘汰,不占位 .sortedWith( compareByDescending<Pair<CardSpec, Double>> { it.second } .thenByDescending { it.first.basePriority } // 同分用固定优先级,保证不抖 .thenBy { it.first.id } // 最后按 id 兜底,结果可复现 ) .take(limit) .map { it.first } .toList() fun weightOf(type: CardType, ctx: LockContext): Double { val byTime = TIME_TABLE[type]?.get(ctx.timeBucket) ?: 0.0 val byCharge = if (ctx.charging) CHARGE_BOOST[type] ?: 0.0 else 0.0 val byBattery = if (ctx.batteryLow) LOW_BATTERY_PENALTY[type] ?: 0.0 else 0.0 // 三项相加后截断到 [0,1],避免权重叠加失控 return (byTime + byCharge + byBattery).coerceIn(0.0, 1.0) }三段排序里,thenBy { it.first.id }这一行看着多余,实际上是保命的:sortedWith对相等元素不保证原序,一旦分数字典是哈希结构,每次启动的卡片顺序都可能不同,用户会觉得界面在乱跳。limit参数按屏高给,常规直板机上 2 到 3 张是舒适区,超过 4 张阅读完成率会明显下降。
4. 版面怎么生成:Compose 动态卡片与 RemoteViews 的尺寸约束
4.1 锁屏可用空间是不确定的:用测量代替设计稿
锁屏和普通页面最大的区别是可用高度完全不由你决定。系统状态栏、指纹区、通知叠层、刘海、折叠屏展开态,每一项都会吃掉几十到上百 dp。设计稿上排得下的三张卡,到某些机型上第三张只剩一条边。
解法是用BoxWithConstraints拿运行时的真实约束,而不是写死 dp 断点。断点本身还是要定,但它判断的是“实际给到我的高度”,不是“这台设备叫什么型号”。
enum class Band { COMPACT, REGULAR, EXPANDED } @Composable fun LockSurface(snapshot: LockSnapshot) { BoxWithConstraints( modifier = Modifier .fillMaxSize() .windowInsetsPadding(WindowInsets.systemBars) // 先扣掉系统栏,再谈断点 ) { val band = when { maxHeight < 420.dp -> Band.COMPACT // 小屏或分屏态,只放一张 maxHeight < 680.dp -> Band.REGULAR // 主流直板机,放两张 else -> Band.EXPANDED // 折叠展开或平板,可放三张 } val limit = when (band) { Band.COMPACT -> 1 Band.REGULAR -> 2 Band.EXPANDED -> 3 } val cards = remember(snapshot, band) { rankCards(snapshot.context, snapshot.candidates, limit) } CardColumn(cards = cards, band = band) } }windowInsetsPadding一定要在测量之前加,否则你算出来的高度是含状态栏的,断点会整体偏移一档,表现出来就是“同一台机器,有时候显示两张有时候显示一张”。remember的 key 里带上band也很关键,不然旋转或分屏变化时卡片数量不会重算。
4.2 卡片编排与降级:三档布局的生成逻辑
降级不只是减少数量,还要降信息密度。我的做法是每张卡定义三套内容槽位:完整版带标题、摘要、副操作;精简版只有标题和一行摘要;最小版只有标题。档位往下走的时候先砍槽位,再砍数量。
@Composable fun CardColumn(cards: List<CardSpec>, band: Band) { Column( modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { cards.forEach { card -> LockCard( card = card, // 密度随档位下降:EXPANDED 全量,COMPACT 只留标题 density = when (band) { Band.EXPANDED -> Density.FULL Band.REGULAR -> Density.COMPACT Band.COMPACT -> Density.MINIMAL } ) } } }spacedBy(12.dp)这个间距在小屏上要降到 8dp,因为卡片之间的留白在低高度下是最先该让位的东西。Density枚举不要用布尔值,后面加了“中等密度”你会想改回来。
4.3 深色与对比度:锁屏上最容易翻车的视觉参数
锁屏底图是用户自己设的,可能是纯白雪山也可能是纯黑夜景,你的卡片如果用了固定半透明背景,在其中一种上必然看不清。稳妥方案是给卡片一层带模糊的遮罩,再叠一层自适应对比度的文字色。
| 参数 | 建议值 | 说明 |
|---|---|---|
| 卡片背景不透明度 | 0.55 ~ 0.72 | 低于 0.55 浅色壁纸上文字发灰 |
| 卡片圆角 | 20 ~ 24 dp | 太小显得像系统通知,太大挤压内容 |
| 标题字号 | 16 ~ 18 sp | 锁屏观看距离比应用内更远 |
| 正文行数上限 | 2 行 | 超过 2 行在小屏上会被截断 |
| 主操作触控区 | ≥ 48 dp | 锁屏上误触代价高 |
提示:不要给锁屏卡片加长按拖拽排序。锁屏状态下触摸事件经常被系统手势区截走,用户会以为是应用卡了。
5. 避坑与排查:锁屏上线的 5 类典型翻车现场
5.1 五类翻车现场的现象、原因与处置
第一类:界面一闪而过,锁屏上根本留不住。现象是通知栏能看到,点亮屏幕瞬间闪过一帧就回到系统锁屏。原因通常是全屏 Intent 被系统降级,或者宿主 Activity 缺少前台服务支撑,在后台启动限制下被直接拦截。解决方式是给拉起路径配一个短时前台服务(类型选与业务相符的),并接受“系统有权降级”这个前提,把通知本身做成可读的完整信息,别指望一定弹得出来。
第二类:解锁后界面残留,主屏上盖着一层卡片。现象是用户解锁进桌面,你的锁屏布局还浮在上面,点哪都没反应。原因是可见性标记没清理,或者主题背景是透明的但窗口没销毁。解决是在onDestroy和onPause里都清一遍setShowWhenLocked(false),同时给主题设一个不透明的兜底背景,避免透明窗口在没有内容时留下一个“鬼影层”。
第三类:定时刷新在后台失效,卡片内容隔夜不变。现象是早上看到的还是昨晚的通勤信息。原因是用了WorkManager做周期任务,它的最小周期是 15 分钟且会被 Doze 进一步压后;换成精确闹钟又要额外权限。我的处理是把“定时刷新”改成“事件驱动”:亮屏、解锁、充电插入这三个广播触发一次重算,这几个时机点天然覆盖了用户真正会看锁屏的时刻。
第四类:解锁几次之后明显卡顿,内存曲线一路上扬。现象是首次进入很流畅,来回锁屏十几次后掉帧。原因是每次创建都把整棵 Compose 树和壁纸 Bitmap 重建了一遍,图片缓存没有复用。解决是把壁纸模糊结果按屏幕尺寸做一级缓存,Activity 用singleInstance复用实例,并把非首屏卡片做成懒加载。
第五类:某些机型上卡片被裁掉半截,折叠屏展开后更严重。现象是同一套代码在不同设备上差出一整张卡的高度。原因是断点用了固定 dp 或者screenHeight,没考虑系统栏和多窗口。解决是统一走BoxWithConstraints加windowInsetsPadding,并且在分屏态下强制降到 COMPACT 档。
5.2 一个可复用的排查顺序
出问题时不要一上来就怀疑布局代码,按这个顺序走会快很多。先确认KeyguardManager.isKeyguardLocked()的实际返回值,判断当前到底是不是锁屏态;再确认窗口属性有没有生效,用dumpsys window看目标 Activity 的 flags 里有没有对应标记;然后确认拉起路径是被放行还是被降级,看通知是否以 heads-up 形式出现过;最后才看渲染,用帧耗时定位是测量阶段还是绘制阶段的问题。
# 查当前锁屏状态与目标窗口的可见性标记 adb shell dumpsys window | grep -E "mDreamingLockscreen|isStatusBarKeyguard|LockHostActivity" # 查目标进程近期有没有被后台限制掐掉 adb shell dumpsys activity processes | grep -A 5 "your.package.name" # 抓一次亮屏到首帧的耗时,重点看 measure 与 draw adb shell am trace-ipc start --buffer 8000 adb shell am trace-ipc stop --dump-file /data/local/tmp/lock.trace这个顺序的价值在于它把“界面不显示”拆成了四层独立问题:状态不对、属性没生效、拉起被拦、渲染太慢。层与层之间的排查手段完全不同,混在一起查只会浪费一整天。
6. 进阶:用离线回放和功耗观测验证「智能」是不是真的智能
规则打分写完之后,最难回答的问题不是“能不能跑”,而是“它到底比固定版面好多少”。我的做法是把决策函数抽成纯函数——不碰 Context、不碰系统 API、输入LockContext加候选列表,输出排序结果——然后拿线上日志回放。
// 纯函数签名:同样的输入必然得到同样的输出,才配拿来回放 fun decide(ctx: LockContext, candidates: List<CardSpec>, limit: Int): List<String> = rankCards(ctx, candidates, limit).map { it.id } // 回放驱动:读取一段时间的上下文快照,统计版面切换频率 fun replay(records: List<Pair<LockContext, List<CardSpec>>>): ReplayReport { var switches = 0 var lastTop: String? = null records.forEach { (ctx, cards) -> val top = decide(ctx, cards, 3).firstOrNull() if (top != null && lastTop != null && top != lastTop) switches++ lastTop = top } // 切换率过高说明离散化不够,用户会觉得版面在乱跳 return ReplayReport(switchRate = switches.toDouble() / records.size) }回放要盯的指标不多,但每一个都得有阈值。
| 指标 | 观测方式 | 健康区间 | 超标说明 |
|---|---|---|---|
| 版面切换率 | 离线回放 | < 15% / 天 | 时段切分太细或权重抖动 |
| 首帧耗时 | 亮屏到首帧日志 | < 250 ms | 卡片构建或图片解码太重 |
| 唤醒次数增量 | 电池统计对比 | < 5 次 / 小时 | 事件驱动被写成了轮询 |
| 卡片点击率 | 埋点 | 与固定版面对比 | 低于基线说明“智能”没带来价值 |
功耗观测这一步别省,我吃过亏:早期版本为了“实时感知场景”注册了三个常驻监听,功能测试全过,用户侧反馈是待机耗电明显变差。后来改成粘性广播读一次加事件驱动,唤醒次数直接掉了一个数量级。锁屏这个位置离用户太近,任何一点功耗异常都会被认为是你的问题,哪怕实际上不是。
现在我做这类功能有个固定习惯:先写纯函数再写界面,先用回放跑一周数据再上线灰度,宁可第一版看起来“不够智能”,也不要让用户觉得锁屏在跟他抢注意力。希望帮到你。
本文还有配套的精品资源,点击获取