一、Android 监听页面无操作,定时返回
已有项目新增需求,需监听页面是否有操作,如果在一定时间内没有操作则返回到指定页面。像一些定制化系统如果长时间停留在工程调试页面是不安全的,所以需要返回到主页,同样像电视盒子在感知无操作可以跳转到广告/屏保。
因为是已有项目,所以希望以尽可能小的代码入侵完成我们的功能。
二、功能分析
-
首先需要一个计时的功能。 一般想法是设置定时器,如果有操作就取消上一个,再新建新的计时器,这里我们用更简单一点的方法,用一个计时器即可,当感知到操作则更新时间,在计时器中如果当前时间与操作时间差值达到一定时间则触发我们的返回业务。
-
通知方式 如何通知,用广播、EventBus/RxBus 或者自定义回调都可以,能把事件回传就行
-
如何获取触摸事件更新 我们的应用基础是 Activity,所以只需要监听 Activity 中的 ACTION_UP 即可,重写 dispatchTouchEvent,插入我们的时间更新代码
三、实现
下面我们来写一段简单的代码:
通知用广播来实现
//ActivityMonitorclass ActivityMonitor { private var recordTime = System.currentTimeMillis()//记录操作时间 private var disposable: Disposable? = null//计时器 private var context: Context? = null companion object { @JvmStatic fun get(): ActivityMonitor { return Holder.holder } } object Holder { @SuppressLint("StaticFieldLeak") val holder = ActivityMonitor() } fun attach(context: Context) { Log.d("zhou", "attach $context") this@ActivityMonitor.context = context Log.d("zhou", "ActivityMonitor >> $context") } //创建计时器 private fun createDisposable(): Disposable { Log.d("zhou", "createDisposable") return Observable.interval(2, TimeUnit.SECONDS) .subscribe { Log.d("zhou", "time === didi......") val time = (System.currentTimeMillis() - recordTime) / 1000 if (time > 5) { Log.d("zhou", "timeout...") this@ActivityMonitor.context!!.sendBroadcast(Intent(GV.MONITOR_TIMEOUT)) disposable?.apply { if (!isDisposed) { dispose() } disposable = null } } else { this@ActivityMonitor.context!!.sendBroadcast(Intent().apply { action = GV.MONITOR_TIME_COUNT putExtra("msg", "update >> $it current diff = $time") }) } } } fun update() { //更新时间 Log.d("zhou", "update operate time.") recordTime = System.currentTimeMillis() if (disposable == null) { disposable = createDisposable() } this@ActivityMonitor.context?.sendBroadcast(Intent().apply { action = GV.MONITOR_TIME_COUNT putExtra("msg", "on touch") }) } fun cancel() { //取消 Log.e("zhou", "cancel") disposable?.also { if (!it.isDisposed) { it.dispose() } disposable = null } }}复制代码
简单说明一下:
attach
在 Application 中设置,其实就为了拿取 context 发广播用update
更新时间cancel
取消计时,比如监控页面计时结束前就返回,我们应该取消这一次计时
这里是 Activity 的调用示例:
class UndoTestActivity : BaseActivity() { //省略... override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) registerReceiver(receiver, IntentFilter().apply { addAction(GV.MONITOR_TIMEOUT) addAction(GV.MONITOR_TIME_COUNT) }) ActivityMonitor.get().update() } override fun onDestroy() { super.onDestroy() unregisterReceiver(receiver) ActivityMonitor.get().cancel() } override fun dispatchTouchEvent(ev: MotionEvent?): Boolean { if(ev?.action==MotionEvent.ACTION_UP){ ActivityMonitor.get().update() } return super.dispatchTouchEvent(ev) } //这里用广播做通知,所以要注册下 private val receiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { intent?.also { when (it.action) { GV.MONITOR_TIME_COUNT -> { val t = "${it.getStringExtra("msg")}\n" text.append(t) } GV.MONITOR_TIMEOUT -> { Log.i("zhou", "UndoTestActivity received msg,finish") ToastUtils.show(this@UndoTestActivity, "timeout,finish!!") finish() } } } } }}复制代码
一般项目都有一个 BaseActivity,在 Base 中引入即可
这里是测试效果动图
四、思考
这么处理,效果不错基础功能也实现了,不过好像插入代码有点多,而且有些页面我们允许可以停留,那也需要再添加一个白名单的功能。
代码优化
1.增加白名单
在初始化时直接传入白名单
class ActivityMonitor{ fun attach(context: Context, list: ArrayList) { this@ActivityMonitor.context = context if (list.isNotEmpty()) { uncheckList.addAll(list) } }}class App:Application(){ @Override protected void attachBaseContext(Context base) { super.attachBaseContext(base); ActivityMonitor.get().attach(base, arrayListOf(MainActivity::class)) }}复制代码
2.减少调用者代码
我们应该把自己的业务逻辑隐藏起来,提供更简洁的调用,让使用者用起来即可
查看源码,我们发现 Activity 的 dispatchToucEvent 有 window 预处理,这个 window 是 PhoneWindow
public boolean dispatchTouchEvent(MotionEvent ev) { if (ev.getAction() == MotionEvent.ACTION_DOWN) { onUserInteraction(); } if (getWindow().superDispatchTouchEvent(ev)) { return true; } return onTouchEvent(ev); }复制代码
那么,我们可以通过代理这 window 的 Callback 实现我们的逻辑,取出默认的 Window.Callback,替换为我们的 Callback,即代理模式,如下:
class MonitorCalback(val default: Window.Callback) : Window.Callback { //省略其他重写函数... override fun dispatchTouchEvent(event: MotionEvent?): Boolean { if (MotionEvent.ACTION_UP == event?.action) { ActivityMonitor.get().update() } return default.dispatchTouchEvent(event) }}//控制类,增加白名单class ActivityMonitor { //... fun inject(activity: Activity, window: Window) { //如果是白名单成员需取消,否则代理 if (uncheckList.contains(activity::class)) { cancel() } else { window.callback = MonitorCalback(window.callback) update() } } //如果是白名单成员,重启计时,否则取消计时 fun onDestroy(activity:Activity){ if(uncheckList.contains(activity::class)){ update() }else{ cancel() } }}//在页面注入及取消class UndoTestActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) ActivityMonitor.get().inject(this,window) } override fun onDestroy() { super.onDestroy() ActivityMonitor.get().onDestroy(this) }}复制代码
当然,有更好的方式的实现代理,使用代理模式需要写的代码有点多,不注意还有可能出错,用动态代理的方式直接插入我们自己的时间更新代码,所谓“面向切面”,如下:
新建我们的代理类,用java的动态代理只能代理接口,如果要代理类可以使用 cglib,这里不展开了。
class WindowCallbackInvocation(val callback: Any) : InvocationHandler { override fun invoke(proxy: Any?, method: Method?, args: Array?): Any? { if ("dispatchTouchEvent" == method?.name) { Log.i("zhou", "WindowCallbackInvocation") val event: MotionEvent = args?.get(0) as MotionEvent if (MotionEvent.ACTION_UP == event.action) { ActivityMonitor.get().update() } } return method?.invoke(callback, *(args ?: arrayOfNulls (0))) }}复制代码
这就是动态代理类所有代码,使用也很简单,修改一下 inject 的代码:
fun inject(activity: Activity, window: Window) { if (uncheckList.contains(activity::class)) { cancel() } else { //代理模式 //window.callback = MonitorCalback(window.callback) //动态代理 val callback = window.callback val handler = WindowCallbackInvocation(callback) val proxy: Window.Callback = Proxy.newProxyInstance(Window.Callback::class.java.classLoader, arrayOf(Window.Callback::class.java), handler) as Window.Callback window.callback = proxy update() }}复制代码
至此,我们的监控类也完成了。
3.是否存在未考虑的点
这个监控类我们只是代理了 Activity 中的 window 即 PhoneWindow,这里可能存在什么隐患?
是的,Activity 中如果使用 Dialog/AlertDialog 或者 PopupWindow 等弹窗,这时我们无法拦截到事件更新,因为他们属于新的 PhoneWindow(Popup使用的是 PopupDecorView 所以用不了Window.Callback),而我们并没有做拦截处理,当出现弹窗时用户在操作但我们没有更新计时器时间,那么会在用户操作弹窗较长时间触发计时器返回,造成使用者操作疑惑,所以我们需要把 inject 方法修改支持 Dialog(不局限于Activity),而 PopupWindow 不适用只能在调用 show 时取消计时器,dismiss 时重开计时器。代码修改如下:
class ActivityMonitor{ //省略其他 fun inject(clz: Class, window: Window) { if (uncheckList.contains(clz)) { cancel() } else { //代理模式 //window.callback = MonitorCalback(window.callback) //动态代理 val callback = window.callback val handler = WindowCallbackInvocation(callback) val proxy: Window.Callback = Proxy.newProxyInstance(Window.Callback::class.java.classLoader, arrayOf(Window.Callback::class.java), handler) as Window.Callback window.callback = proxy update() } } fun onDestroy(clz: Class ) { if (uncheckList.contains(clz)) { update() } else { cancel() } }}复制代码
这不是最好的解决方法,如果应用本身就存在大量弹窗,那么我应该会当场去世吧。 所以,这份代码还有优化的空间。
五、总结
下面我们捋一下思路,监控页面有无操作即监控手指抬起,重写 dispatchTouchEvent
可以达到我们的目的,但是在已有项目中使用,需简化调用逻辑,封装调用让原业务只需启用时调用inject,结束时调 用onDestory,做一个优秀的 sdk。
注: 文中代码均已上传 Github
已开通微信公众号码农茅草屋,有兴趣可以关注,一起学习