![](https://i-blog.csdnimg.cn/blog_migrate/de644e063309eef465a8f323336d1dc4.jpeg)
一、为什么需要自定义锁屏页
锁屏作为一种黑白屏时代就存在的手机功能,至今仍发挥着巨大作用,特别是触屏时代的到来,锁屏的功用被发挥到了极致。多少人曾经在无聊的时候每隔几分钟划开锁屏再关上,孜孜不倦,其酸爽程度不亚于捏气泡膜。确实,一款漂亮的锁屏能为手机增色不少,但锁屏存在的核心目的主要是三个:保护自己手机的隐私,防止误操作,在不关闭系统软件的情况下节省电量。
当下,各个款式的手机自带的系统锁屏完全能够满足这些需求,而且美观程度非凡,那么开发者为什么仍然需要构建自定义锁屏呢?让我们试想一个场景,一位正在使用音乐播放器听歌的美女用户,在没有播放器自定义锁屏的情况下,切换一首歌需要几步(参考自同类文章):
1. 点亮手机屏幕
2. 解开系统锁屏
3. 打开音乐播放器
4. 切歌再熄灭屏幕
这时的她估计已经被广场舞的歌曲骚扰了有10秒,续了10次命,这是我们程序员不愿意看到的,所以有必要依靠我们灵活的双手构建出自定义的音乐锁屏页,将切歌过程被压缩为两步:点亮屏幕和切歌,顺便可以看看歌词。如果再加个开启和关闭自定义锁屏的开关,就能完美解决用户的痛点。
二、自定义锁屏页的基本原理
然而,要实现一个自定义锁屏是一件繁琐的事情,因为系统有100种方法让这个非本地的锁屏待不下去。但是,人类的智慧是无限的,程序员需要逆流而上。
Android系统实现自定义锁屏页的思路很简单,即在App启动时开启一个service,在Service中时刻监听系统SCREEN_OFF的广播,当屏幕熄灭时,Service监听到广播,开启一个锁屏页Activity在屏幕最上层显示,该Activity创建的同时会去掉系统锁屏(当然如果有密码是禁不掉的)。示意图如下:
道理很简单,我们这里需要讨论的是细节。
1. 广播注册
Service是普通的Service,在应用启动时直接startService,与应用同一个进程即可。此外,SCREEN_OFF广播监听必须是动态注册的,如果在AndroidManifest.xml中静态注册将无法接收到SCREEN_OFF广播,这点在Android官方文档中有明确说明,即需要通过如下代码注册:
对应的BroadcastReceiver定义如下:
关于启动Activity时Intent的Flag问题,如果不添加FLAG_ACTIVITY_NEW_TASK的标志位,会出现“Calling startActivity() from outside of an Activity”的运行时异常,毕竟我们是从Service启动的Activity。Activity要存在于activity的栈中,而Service在启动activity时必然不存在一个activity的栈,所以要新起一个栈,并装入启动的activity。使用该标志位时,也需要在AndroidManifest中声明taskAffinity,即新task的名称,否则锁屏Activity实质上还是在建立在原来App的task栈中。
标志位FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS,是为了避免在最近使用程序列表出现Service所启动的Activity,但这个标志位不是必须的,其使用依情况而定。
2. Activity设置
锁屏的activity内部也要做相应的配置,让activity在锁屏时也能够显示,同时去掉系统锁屏。当然如果设置了系统锁屏密码,系统锁屏是没有办法去掉的,这里考虑没有设置密码的情况。
典型的去掉系统锁屏页的方法是使用KeyguardManager,具体代码如下:
其中,KeyguardManager是锁屏管理类,我们通过getSystemService()的方式获取实例对象mKeyguardManager,调用该对象的newKeyguardLock()方法获取KeyguardManager的内部类KeyguardLock的实例mKeyguardLock,该方法传入的字符串参数用于标识是谁隐藏了系统锁屏,最后调用mKeyguardLock的disableKeyguard()方法可以取消系统锁屏。
上述方法已经不推荐使用,可以使用更好的方法来替代。我们在自定义锁屏Activity的onCreate()方法里设定以下标志位就能完全实现相同的功能:
FLAG_DISMISS_KEYGUARD用于去掉系统锁屏页,FLAG_SHOW_WHEN_LOCKED使Activity在锁屏时仍然能够显示。当然,不要忘记在Manifest中加入适当的权限:
3. 屏蔽按键
当自定义锁屏页最终出现在手机上时,我们总希望它像系统锁屏页那样屹立不倒,所有的按键都不能触动它,只有通过划瓶或者指纹才能解锁,因此有必要对按键进行一定程度上的屏蔽。针对只有虚拟按键的手机,我们可以通过隐藏虚拟按键的方式部分解决这个问题,具体方法在后文会介绍。但是当用户在锁屏页底部滑动,隐藏后的虚拟按键还是会滑出,而且如果用户是物理按键的话就必须进行屏蔽了。
Back键和Menu键可以通过重写onKeyDown()方法进行屏蔽:
Home键与Recent键(调出最近打开应用的按键)的点击事件是在framework层进行处理的,因此onKeyDown与dispatchKeyEvent都捕获不到点击事件。关于这两个按键的屏蔽方法,网上相关的资料有很多,有的用到了反射,有的通过改变Window的标志位和Type等,总的来说这些方法只对部分android版本有效,有的则完全无法编译通过。其实,这么做的目的无非是为了实现一个纯粹的锁屏页,但是这种做法有些画蛇添足,容易造成锁屏页的异常崩溃,我们要满足的是用户在锁屏页的快捷操作,Home键和Recent键无关痛痒,完全可以不管,少一些套路,多一点真诚嘛。
4. 划屏解锁
做完以上几步,当屏幕熄灭后,再打开屏幕就能够看到我们的自定义锁屏页了,但是这时候,就算划破手指也无法解锁。所以,接下来要实现划屏解锁。
划瓶解锁的基本思路很简单,当手指在屏幕上滑动时,拦截并处理滑动事件,使锁屏页面随着手指运动,当运动到达一定的阀值时,用户手指松开手指,锁屏页自动滑动到屏幕边界消失,如果没有达到运动阀值,就会自动滑动到起始位置,重新覆盖屏幕。
为了将划屏逻辑与页面内容隔离开来,我们在锁屏页面布局中添加一个自定义的UnderView,这个UnderView填充整个屏幕,位于锁屏内容View(将其引用称之为mMoveView,并传入到UnderView中)的下方,所有划屏相关的事件都在这里拦截并处理。
mMoveView是锁屏页的显示内容,除了处理一些简单的点击事件,其他非点击事件序列都由底层的UnderView进行处理。只需要重写UnderView的onTouchEvent方法就能够实现:
其中,mStartX记录滑动操作起始的x坐标,handleMoveView方法控制mMoveView随手指的移动,doTriggerEvent处理手指离开后mMoveView的移动动画。两个方法的定义如下:
在handleMoveView()中,首先计算当前触点x坐标与初始x坐标mStartX的差值movex,然后调用mMoveView的setTranslationX方法移动。值得注意的是,目前setTranslationX方法只能在Android 3.0以上版本使用,如果采用动画兼容库nineoldandroid中ViewHelper类提供的setTranslation方法,则没有这个问题。scrollTo与scrollBy也可以实现移动,但是只是移动View的内容,并不能移动View本身。另外就是通过修改布局参数LayoutParams实现移动,虽然没有版本的限制,用起来相对复杂。这里我们采用setTranslationX,为了简洁,也是为了能够与后续使用的属性动画相统一。
此外,我们可以通过getBackground()获取UnderView的背景,并根据已划开屏幕占整个屏幕的百分比调用setAlpha方法改变背景的透明度,做出抽屉拉开时的光影变化效果。
当手指离开屏幕,doTraiggerEvent方法会对滑动的距离与阀值进行一个比较,此处的阀值为0.4*屏幕宽度,如果低于阀值,则通过ObjectAnimator在0.25s将mMoveView移动到初始位置,同时在ObjectAnimator的AnimatorUpdateListener的onAnimationUpdate方法中更新背景透明度;如果低于阀值,以同样的方式将mMoveView移出屏幕右边界,然后将Activity干掉,具体做法是为animator增加一个AnimatorListenerAdapter的监听器,在该监听器的onAnimationEnd方法中使用在Activity中定义的mHandler发送finish消息,完成解锁,效果如下图:
三、透明栏与沉浸模式
沉浸模式与透明栏是两个不同的概念,由于某些原因,国内一些开发或产品会把这两个概念混淆。不过没关系,在接下来的内容我们会对这两个概念进行详细的解释和区分,并应用这两种不同的模式进一步完善已经初具模样的锁屏页。
1. 沉浸模式
什么是沉浸模式?从4.4开始,Android 为 “setSystemUiVisibility()”方法提供了新的标记 “SYSTEM_UI_FLAG_IMMERSIVE”以及”SYSTEM_UI_FLAG_IMMERSIVE_STIKY”,就是我们所谈的沉浸模式,全称为 “Immersive Full-Screen Mode”,它可以使你的app隐藏状态栏和导航栏,实现真正意义上的全屏体验。
之前 Android 也是有全屏模式的,主要通过”setSystemUiVisibility()”添加两个Flag,即”SYSTEM_UI_FLAG_FULLSCREEN”,”SYSTEM_UI_FLAG_HIDE_NAVIGATION”(仅适用于使用导航栏的设备,即虚拟按键)。
这两个标记都存在一些问题,例如使用第一个标记的时候,除非 App 提供暂时退出全屏模式的功能(例如部分电子书软件中点击一次屏幕中央位置),用户是一直都没法看见状态栏的。这样,如果用户想去看看通知中心有什么通知,那就必须点击一次屏幕,显示状态栏,然后才能调出通知中心。
而第二个标记的问题在于,Google 认为导航栏对于用户来说是十分重要的,所以只会短暂隐藏导航栏。一旦用户做其他操作,例如点击一次屏幕,导航栏就会马上被重新调出。这样的设定对于看图软件,视频软件等等没什么大问题,但是对于游戏之类用户需要经常点击屏幕的 App,那就几乎是悲剧了——这也是为什么你在 Android 4.4 之前找不到什么全屏模式会自动隐藏导航栏的应用。
Android 4.4 之后加入的Immersive Full-Screen Mode 允许用户在应用全屏的情况下,通过在原有的状态栏/导航栏区域内做向内滑动的手势来实现短暂调出状态栏和导航栏的操作,且不会影响应用的正常全屏,短暂调出的状态栏和导航栏会呈半透明状态,并且在一段时间内或者用户与应用内元素进行互动的情况下自动隐藏,沉浸模式的四种状态如下图。(参考http://www.jcodecraeer.com/a/anzhuokaifa/androidkaifa/2015/0616/3047.html)
状态1代表没有进入沉浸模式时页面的状态,仍然可以看到Status Bar和Navigation Bar;状态2代表用户第一次进入沉浸模式时,系统的提示弹窗,告诉用户如何在沉浸模式下呼出Status Bar和Navigation Bar;状态3代表沉浸模式,可以看到Status Bar和Navigation Bar都被隐藏;状态4代表用户在Sticky沉浸模式下呼出Status Bar和Navigation Bar,可以看到两个Bar重新出现,但是过一段时间能够自动隐藏。
一般来说,沉浸模式的标记与其他Full Screen相关的Flag搭配起来才能达到我们想要的效果,即通过沉浸模式标记规定状态栏status bar和导航栏navigation bar显示和隐藏的运转逻辑,通过其他标签设定状态栏和导航栏显示或隐藏,以及显示或隐藏的样子。这些常见的Flag及相应功能如下表:
如此多的标签,看起来非常乱,但用起来却非常简单和明确,感兴趣的开发者可以自由搭配来测试一下。下面,我们通过一个例子,将这些标签应用于锁屏页,实现对Navigation Bar的自动隐藏,同时保留Status Bar。代码非常简单,在Activity的onCreate()方法中使用:
总共用到了5个Flag:SYSTEM_UI_FLAG_LAYOUT_STABLE保持整个View稳定,使View不会因为SystemUI的变化而做layout;SYSTEM_UI_FLAG_IMMERSIVE_STIKY,能够在隐藏的bar被呼出时(比如从屏幕下边缘开始向上做滑动手势),使bar在无相关操作的情况下自动再次隐藏;对于SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION,开发者容易被其中的HIDE_NAVIGATION所迷惑,其实这个Flag没有隐藏导航栏的功能,只是控制导航栏浮在屏幕上层,不占据屏幕布局空间;SYSTEM_UI_FLAG_HIDE_NAVIGATION,才是能够隐藏导航栏的Flag;SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN,由上面可知,也不能隐藏状态栏,只是使状态栏浮在屏幕上层。
需要注意的是,这段代码除了需要加在Activity的OnCreate()方法中,也要加在重写的onWindowFocusChanged()方法中,在窗口获取焦点时再将Flag设置一遍,否则可能导致无法达到预想的效果。
此外,有个部份要稍微留意一下,如果不希望界面的内容被上拉到状态栏(Status bar)的话,要记得在界面(Layout)XML文件中,在最外层Layout中将fitsSystemWindows属性设置为true。如下:
设置了前文的5个Flag之后,锁屏页效果图如下:
手指在屏幕底端上划,Navigation Bar会弹出,悬浮于锁屏页底部,随后自动消失。Status Bar也按照我们预期的那样,悬浮在上方,没有隐藏。
2. 透明栏
什么是透明栏?Google 在 Android 4.4 的 API 描述页面里提到了“Translucent system UI styling”,即半透明化的系统UI风格。这个“半透明化”包括了状态栏和通知栏,当开发者让应用支持这个新特性的时候,状态栏和导航栏可以单独/同时变为渐变的半透明样式,如下图:
在 Android 5.0 之后引入了 Material Design,状态栏和导航栏也玩出了更多花样。现在除了原有的“半透明”模式以外,还有“全透明”以及“变色”模式,一种会完全隐藏背景,另一种可以取色作为背景颜色,多种样式的透明栏如下图(上图为透明状态栏,下图为透明导航栏):
所以,透明栏只是能够改变状态栏和导航栏的颜色,并不像沉浸模式那样隐藏状态栏和导航栏,两者是有本质区别的。
对于Android 4.4以上5.0以下的版本,设置透明状态栏的方式如下:
对于Android 5.0及以上版本,设置透明状态栏的方法如下:
除了要清理掉4.4的FLAG_TRANSLUCENT_STATUS外,还要配合SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN和SYSTEM_UI_FLAG_LAYOUT_STABLE,添加标志位FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS,并调用setStatusBarColor设置状态栏的颜色为透明。
在综合运用了沉浸模式和透明栏之后,锁屏页效果如下:
四、指纹解锁
到这里,我们的锁屏页已经基本完工,完全能够非常优雅地解决用户的痛点,但是跟当下App自定义锁屏页的区别并不明显。接下来对新型号手机普遍具备的指纹解锁功能的考虑,则能够为锁屏页增色不少。
1. 指纹识别无法解锁自定义锁屏页的问题
持有指纹解锁手机的用户在使用App自定义锁屏页时会出现一种困惑,当你点亮屏幕,能够看到自定义锁屏页,在使用指纹解锁成功之后(部分机型指纹解锁操作只能在系统锁屏页进行),自定义锁屏页依然存在,你还是需要划开自定义锁屏页,才能看到手机主界面。
解决这一问题的方案是一种取巧的方法,那就是在锁屏页的service中监听ACTION_USER_PRESENT广播。ACTION_USER_PRESENT广播是系统锁屏解锁广播,当系统锁屏页解锁时就会触发。如果在接收到这一广播时,将自定义锁屏页finish掉,就能避免在指纹解锁成功后自定义锁屏页仍然显示的问题。但是细心的读者会发现这种解法在逻辑上还存在问题,因为在用户没有设置锁屏密码的情况下,前文自定义锁屏页在onCreate()时设置的FLAG_DISMISS_KEYGUARD标志位能够轻易解锁系统的锁屏页,并触发ACTION_USER_PRESENT广播,此时自定义锁屏页的Service接收到这一广播后,发finish广播给自定义锁屏页,导致自定义锁屏页刚create就finish掉了,永远不可能出现。
因此,我们必须对场景进行区分,只在有锁屏密码的情况下,才对接收到的ACTION_USER_PRESENT广播进行处理,finish自定义锁屏页。即在BroadcastReceiver的onReceive()方法中加入如下代码:
这里KeyguardManager对象km的isKeyguardSecure()方法就是用来判断是否设置了锁屏密码。NOTIFY_USER_PRESENT是自定义广播,用来通知锁屏页Activity调用finish方法。
这种做法是合理的,因为如果没有设置锁屏密码,FLAG_DISMISS_KEYGUARD标志位解锁系统锁屏之后,到达上述代码块,isKeyguardSecure()返回为false,不会导致自定义锁屏页Activity的finish操作。而如果设置了锁屏密码,FLAG_DISMISS_KEYGUARD必然无法解锁系统锁屏,到达不了上述代码块,也不会finish。这样就避免了自定义锁屏页刚创建出来就将自己finish掉的困境。另一方面,其他非FLAG_DISMISS_KEYGUARD方式触发的解锁,比如指纹解锁,都会使Activity消失,满足了需求。
2. 自定义锁屏页下指纹识别无法使用的问题
此外,有些手机型号,比如小米,在自定义锁屏页罩在系统锁屏页之上时(设置有锁屏密码),指纹解锁是无效的,也就是必须要划开自定义锁屏页,在系统锁屏页上才能进行指纹解锁。为了改善这种体验,我们可以在Activity中引入指纹解锁API,识别指纹并解锁,具体代码如下:
当然,不要忘记在Manifest中加入适当的权限:
在调用指纹识别功能之前,我们需要判断指纹识别功能是否可用,以及APP是否有相应的权限。这一过程体现在isFingerprintAuthAvailable()中,第一步是获取KeyguardManager对象,调用isKeyguardSecure()判断是否设置有锁屏密码,如果有,则需进一步判断。checkSelfPermission用来判断APP是否有指纹识别的权限(SDK 23要求),如果有则获取FingerprintManager对象,调用该对象的isHardwareDetected()方法判断指纹识别硬件是否可用,调用hasEnrolledFingerprints()判断是否有事先录入好的指纹,只有以上条件都满足,接下来才能调用指纹识别功能。
指纹识别的调用体现在startFingerPrintListening()方法中,主要就是调用FingerprintManager的方法
其中,crypto参数代表Android6.0中crypto objects的wrapper class,可以通过该对象使authenticate过程更加安全,也可以不使用,这里我们将其设为null;cancel用来取消anthenticate(),我们new出一个对象传入就可以;flags是标志位,设置为0;callback为指纹识别回调,包含指纹识别的核心方法:onAuthenticationError()是指纹匹配连续失败后的回调(几十秒后才能继续匹配),onAuthenticationSucceeded()是指纹匹配成功的回调,onAuthenticationFailed()是指纹匹配失败时的回调。我们在这几个方法中做相应的处理即可,在onAuthenticationSucceeded()方法中调用finish(),就能够在指纹识别成功后关闭Activity。
五、总结
通过以上内容的分享,本鹅希望能够对大家的开发有所帮助,如果内容有问题,也希望大家指点。综上所述,在Android上实现自定义锁屏页并不是一件复杂的事情,关键是对一些技术点的把握要比较清楚。Service中启动Activity的正确方法,广播静态注册与动态注册的差别,touch事件的分发传播机制,透明栏与沉浸模式的综合运用,以及指纹识别新技术的应用,都有很多值得推敲的地方。笔者当初实现自定义锁屏页时,没有太多思考,有时照搬前人的做法,有时各种flag随便添加,有时新旧API混淆,虽然实现了需求,但是代码不够简洁,可读性也差。因此,在今后的开发过程中,除了要快速实现需求,还要在随后的维护中,多多思考和研究,使代码能够达到“少一行不行,多一行难受”的境界。