
1. 无操作这三个字比写超时逻辑难多了1.1 产品经理一句话落地要抠三层细节android 无操作超时返回登录界面字面需求简单得不能再简单用户在App里待了一会儿不动就把他踢回登录页。这种需求多出现在银行、支付、政务、内部协作工具里核心目的是防止手机被他人拿走之后应用里还停留着上一任用户的敏感数据。我接到这个需求时第一反应也是不就是个倒计时嘛。真做起来才发现最麻烦的不是倒计时而是无操作到底怎么定义。你以为是这样的用户5分钟内没碰屏幕超时登出。实际产品会追问用户滚动屏幕算操作吗当然算。用户只在软键盘上按了几次候选词算操作吗算不算输入法窗口的事件我们的Activity可能压根收不到。用户下拉了通知栏在通知栏里回了个微信算操作吗如果算那么他回来时并不会超时如果不算他可能已经在后台待了10分钟回来就该被踢。用户按了音量键调音量算操作吗很多产品觉得不算但技术上如果不拦截系统会给Activity抛一个KeyEvent最后被当成操作重置了计时器。所以第一步我拉着产品拉了一个操作类型矩阵把每个 算不算重置计时器 的选项都确认清楚省得后面扯皮。这张表长这样事件类型是否重置无操作计时说明触摸屏幕点击/滑动/长按是最基本的交互物理按键返回、Home、菜单按产品策略定返回键建议算Home键应用内收不到音量加减键建议不算调音量不是业务操作软键盘输入候选词点击建议不算事件发生在输入法窗口不经过应用Window较难处理应用内Dialog的点击建议算Dialog是独立窗口需要额外监听后面细说通知栏下拉 / 通知操作建议不算系统层面的操作不应重置业务会话屏幕熄灭后亮屏解锁建议不算Keyguard事件不属于应用交互除非你自定义解锁页面前后台切换按产品策略定是切后台就锁还是超时才锁需要单独约定这张表看起来啰嗦但真的有用。因为无操作超时返回登录界面涉及的不只是技术还有用户预期。如果产品说只要用户有任意操作就不超时那技术方案可以粗暴一点如果产品说只有业务有效操作才算那就要做事件过滤复杂度直接上一个台阶。1.2 业务场景决定了你要不要做二次确认在讨论技术方案之前我还建议你先确认一个问题超时之后是直接硬跳登录页还是先给用户一个可取消的倒计时警告做过金融类App的朋友应该知道《网上银行系统信息安全通用规范》这类行业标准里通常会要求在会话超时前给出提示让用户选择是否继续。无操作超时返回登录界面如果直接硬跳用户正在读一篇长文章或者正在输入一长段话突然被打断体感非常差轻则骂一句难用重则直接流失。所以我们项目最终做的是超时前30秒弹一个非全屏的提示框显示您已长时间未操作将在30秒后退出登录并提供继续使用按钮。用户点了按钮就重置计时器不点就倒计时归零后跳登录页。这个设计后来在灰度数据里很关键因为大量误杀场景其实是用户在看详情页或输入给了挽救按钮之后超时退出率下降了一多半。这个决策直接影响技术实现你的超时模块不能只在最后一刻触发还得有一个预提醒回调。所以后面封装Manager时我留了两个时间参数warningTimeMillis提前多少毫秒提醒和timeoutTimeMillis无操作多久超时触发顺序是warning然后timeout逻辑写在同一个轮询里。2. 技术选型为什么我选了时间戳轮询而不是倒计时2.1 三种常见做法各自的坑把需求基本厘清之后技术方案其实有几种我按实现路径排了一下CountDownTimer / Handler.postDelayed 做纯倒计时。每次操作时cancel掉上一个Timer重新start。思路直观但有个致命问题线程被调度延迟、系统进入Doze打盹模式、主线程被GC卡住都会导致倒计时不准。更麻烦的是倒计时一旦启动中途想改变剩余时间或动态调整超时时长代码会变得很乱。Handler 每秒轮询一次每次检查当前时间 - 最后操作时间。这个方案我最终用了。它不关心具体耗时准不准只关心最后一次操作的时间戳。轮询tick也走主线程正常情况下误差在1秒内即使App在后台被系统掐住了、tick没执行等用户切回前台tick恢复时一算时间差发现早就超时了直接登出。这个行为正是我们想要的。AlarmManager BroadcastReceiver 做全局定时触发。这个最重能保证进程在被杀的情况下也收到广播但需要动态广播、权限适配而且频繁唤醒CPU会消耗电量。除非产品明确要求App进程被杀也要登出登录态一般不需要因为进程被杀后登录态还留在本地才是问题那是安全存储的范畴否则不建议为超时登出引入Alarm。最终我选定了第二种全局只维护一个最后操作时间戳用一个1秒的循环tick去检查差值。这样做的好处有几点时间戳是系统时间不依赖handler的精确回调。动态修改超时阈值远程配置只需改一个变量下一个tick生效。超时和预警两个逻辑共用同一个检查函数不会出现两台计时器打架。2.2 SessionTimeoutManager 的核心骨架先给你看一下不含业务逻辑的Manager核心。我用的是Kotlin但思路和Java完全一致。object SessionTimeoutManager { const val STATE_NORMAL 0 const val STATE_WARNING 1 const val STATE_TIMEOUT 2 private const val DEFAULT_TIMEOUT 5 * 60 * 1000L // 默认5分钟 private const val DEFAULT_WARNING 30 * 1000L // 默认提前30秒提醒 private const val TICK_INTERVAL 1000L // 轮询间隔1秒 Volatile var timeoutMillis: Long DEFAULT_TIMEOUT set(value) { field value lastActiveTime System.currentTimeMillis() // 修改阈值时重置最后一次操作时间避免新配置立即误伤 } Volatile var warningMillis: Long DEFAULT_WARNING var lastActiveTime System.currentTimeMillis() private set private val handler Handler(Looper.getMainLooper()) private var tickRunnable: Runnable? null private var currentState STATE_NORMAL private var isLogoutPending false // 由业务层注入用于执行清理登录态、跳转登录页等操作 var onTimeoutAction: (() - Unit)? null var onWarningAction: (() - Unit)? null fun start() { if (tickRunnable ! null) return tickRunnable object : Runnable { override fun run() { checkTimeout() handler.postDelayed(this, TICK_INTERVAL) } } handler.post(tickRunnable!!) } fun stop() { tickRunnable?.let { handler.removeCallbacks(it) } tickRunnable null } // 任何用户操作到达时调用 fun recordUserAction() { lastActiveTime System.currentTimeMillis() if (currentState STATE_WARNING) { currentState STATE_NORMAL // 如果正在显示“即将退出”的提示框这里应该通知UI关闭 } isLogoutPending false } private fun checkTimeout() { val elapsed System.currentTimeMillis() - lastActiveTime if (isLogoutPending) return when { elapsed timeoutMillis - { currentState STATE_TIMEOUT isLogoutPending true onTimeoutAction?.invoke() } elapsed timeoutMillis - warningMillis - { if (currentState ! STATE_WARNING) { currentState STATE_WARNING onWarningAction?.invoke() } } else - { if (currentState STATE_WARNING) { currentState STATE_NORMAL // 这里可以通知UI取消预警框 } } } } }几个关键点start()和stop()建议在Application的onCreate里和ActivityLifecycleCallbacks配合使用而不是在某个Activity里启动否则页面一销毁计时器就没了。recordUserAction()是全局唯一入口。后面要替换成Window.Callback就是调用这里。isLogoutPending这个标志位很重要它防止重复触发超时。跳转登录页的过程中新页面会唤起生命周期如果不做保护可能会触发第二次超时跳转。2.3 tick为什么要定1秒以及后台进程被冻结怎么办1秒的轮询在主线程上跑很多人担心性能。其实每次tick只做一次时间差比较没有IO、没有网络消耗可以忽略。之前我们用Ticker库或者Choreographer试过精度高但没必要因为业务超时本来就不要求毫秒级。1秒的误差在大多数产品眼里是零感知。那有人会问App切到后台Handler被系统冻结tick就不跑了等用户回来的时候是不是要等到下一个tick才判定对就是这样的。但它只延迟最多1秒实际上Handler从冻结恢复后会立即执行已post的Runnable用户几乎感知不到。更关键的是判断依据是时间戳而不是tick次数所以哪怕后台冻结了10分钟再回来elapsed会立刻算出10分钟直接触发超时不会出现后台时间不算数的问题。3. 全局监听用户操作的落地姿势Window.Callback 比 OnTouchListener 更稳3.1 三个监听方案为什么选 Window.Callback要在整个App的界面范围内捕捉最后一次操作最常见的思路有三种在每个BaseActivity里重写dispatchTouchEvent和dispatchKeyEvent。缺点显而易见如果项目已经有BaseActivity还好如果没有还要强制所有页面继承而且如果某些页面用了Fragment嵌套、自定义View、第三方弹窗很容易漏。在每个Activity的根View上setOnTouchListener。侵入性没那么强但根View可能被子类View替换换页面时要记得重新设置而且一旦业务代码自己给根View设了OnTouchListener就冲突了。通过ActivityLifecycleCallbacks在页面onResume时替换window.callback。这是全局方案不要求页面继承任何基类。所有事件在系统Window向Activity分发时会先经过你设置的Callback你在里面记录时间然后原封不动地继续转发给原Callback。它相当于在Activity外面包了一层代理几乎不侵入业务代码。我选了第三种。理由很简单项目里有三十多个Activity很多还是第三方的库页面扫码页、地图页不可能逐一继承统一基类而ActivityLifecycleCallbacks是所有Activity创建后一定回调的钩子能覆盖全部页面。3.2 完整代码ActivityLifecycleCallbacks 代理Callback接下来是我在项目里用的具体实现。先看代理Callback它实现了Window.Callback接口但只关心三个方法dispatchTouchEvent、dispatchKeyEvent、dispatchGenericMotionEvent外接鼠标/遥控器其余方法全部转发给原Callback。class TimeoutWindowCallback( private val originalCallback: Window.Callback, private val activity: Activity ) : Window.Callback { override fun dispatchTouchEvent(event: MotionEvent): Boolean { if (event.action MotionEvent.ACTION_DOWN) { SessionTimeoutManager.recordUserAction() } return originalCallback.dispatchTouchEvent(event) } override fun dispatchKeyEvent(event: KeyEvent): Boolean { if (event.action KeyEvent.ACTION_DOWN) { // 可以根据产品策略过滤音量键等非业务按键 if (!isNonBusinessKey(event.keyCode)) { SessionTimeoutManager.recordUserAction() } } return originalCallback.dispatchKeyEvent(event) } override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean { SessionTimeoutManager.recordUserAction() return originalCallback.dispatchGenericMotionEvent(event) } // 其余Window.Callback接口方法直接转发省略非核心代码 override fun onWindowFocusChanged(hasFocus: Boolean) originalCallback.onWindowFocusChanged(hasFocus) override fun dispatchPopulateAccessibilityEvent(event: AccessibilityEvent): Boolean originalCallback.dispatchPopulateAccessibilityEvent(event) override fun onCreatePanelView(featureId: Int): View? originalCallback.onCreatePanelView(featureId) override fun onCreatePanelMenu(featureId: Int, menu: Menu): Boolean originalCallback.onCreatePanelMenu(featureId, menu) override fun onPreparePanel(featureId: Int, view: View?, menu: Menu): Boolean originalCallback.onPreparePanel(featureId, view, menu) override fun onMenuOpened(featureId: Int, menu: Menu): Boolean originalCallback.onMenuOpened(featureId, menu) override fun onMenuItemSelected(featureId: Int, item: MenuItem): Boolean originalCallback.onMenuItemSelected(featureId, item) override fun onWindowAttributesChanged(attrs: WindowManager.LayoutParams) originalCallback.onWindowAttributesChanged(attrs) override fun onContentChanged() originalCallback.onContentChanged() override fun onPanelClosed(featureId: Int, menu: Menu) originalCallback.onPanelClosed(featureId, menu) override fun onSearchRequested(): Boolean originalCallback.onSearchRequested() override fun onSearchRequested(event: SearchEvent): Boolean originalCallback.onSearchRequested(event) override fun onActionModeStarted(mode: ActionMode) originalCallback.onActionModeStarted(mode) override fun onActionModeFinished(mode: ActionMode) originalCallback.onActionModeFinished(mode) override fun onWindowStartingActionMode(callback: ActionMode.Callback): ActionMode? originalCallback.onWindowStartingActionMode(callback) override fun onWindowStartingActionMode(callback: ActionMode.Callback, type: Int): ActionMode? originalCallback.onWindowStartingActionMode(callback, type) private fun isNonBusinessKey(keyCode: Int): Boolean { return keyCode KeyEvent.KEYCODE_VOLUME_UP || keyCode KeyEvent.KEYCODE_VOLUME_DOWN || keyCode KeyEvent.KEYCODE_VOLUME_MUTE } }然后在Application里注册生命周期回调在onActivityResumed时给每个Activity的Window套上代理并在onActivityPaused时把Agent摘掉。class App : Application() { override fun onCreate() { super.onCreate() registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks { private val originalCallbacks HashMapActivity, Window.Callback() override fun onActivityResumed(activity: Activity) { if (activity is LoginActivity) return // 登录页不适用超时逻辑 if (activity.window.callback is TimeoutWindowCallback) return originalCallbacks[activity] activity.window.callback activity.window.callback TimeoutWindowCallback(activity.window.callback, activity) SessionTimeoutManager.start() // 确保全局计时器已启动 } override fun onActivityPaused(activity: Activity) { val original originalCallbacks.remove(activity) if (original ! null activity.window.callback is TimeoutWindowCallback) { activity.window.callback original } } // 其余生命周期回调不需要处理 override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {} override fun onActivityStarted(activity: Activity) {} override fun onActivityStopped(activity: Activity) {} override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {} override fun onActivityDestroyed(activity: Activity) {} }) SessionTimeoutManager.onWarningAction { showCountDownDialog() } SessionTimeoutManager.onTimeoutAction { forceLogout() } } }注意两个细节activity.window.callback是Activity自身如果不保存代理前原值在Activity销毁前没有恢复可能会影响系统后续调Activity的onWindowAttributesChanged等逻辑。所以我用一个Map保存每个Activity被替换前的原callback在onActivityPaused时原样恢复。onActivityResumed里要判断当前是不是登录页。登录页本身不需要超时否则用户在登录页发一会儿呆就被踢出登录页体验很怪。3.3 特殊输入事件音量键、通知栏、输入法候选词dispatchKeyEvent会拦截所有物理按键包括音量键。如果你的产品认为调音量不算用户有效操作就在isNonBusinessKey里过滤掉如果产品很佛系觉得按了键就算有动作也可以不过滤。通知栏下拉、系统级弹窗的事件不会进入Activity的Window所以window.callback方案天然收不到。这是好事也是坏事。好事是它不会错误地重置计时器坏事是如果产品希望用户在通知栏里的互动也算活人证据那就做不到。我的建议是明确告知产品通知栏不属于应用内操作不做计数。软键盘候选词点击也是同理输入法窗口是独立Window事件被IME消费Activity收不到。所以用户在候选词上点来点去计时器不会重置。如果你们的产品逻辑是点候选词也算操作就得在EditText的OnTextChangedListener里调recordUserAction()但要注意这个方案只能在输入类页面生效不属于全局能力。4. 超时跳转不是startActivity那么简单任务栈、生命周期和假重置4.1 清空任务栈的正确姿势超时后要跳转登录页很多新手会直接startActivity(Intent(this, LoginActivity::class.java))这种写法在无操作超时场景下是致命的。用户按返回键屏幕上就出现之前那个还在登录态的页面等于所谓的退出登录根本没生效顶多是把登录页盖在上面。正确的清栈姿势是使用FLAG_ACTIVITY_NEW_TASKFLAG_ACTIVITY_CLEAR_TASK。前者表示从非Activitycontext启动时开一个新任务后者表示在启动登录页前先把这个任务里的所有Activity全部销毁。fun forceLogout() { // 先执行业务退出逻辑 loginRepository.clearLocalSession() val intent Intent(this, LoginActivity::class.java).apply { flags Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK } startActivity(intent) }如果登录页有自己的启动模式比如singleTask那CLEAR_TASK和它的作用会重叠只要确认登录页launchMode正确即可。我个人更建议用Intent flag因为这样即使登录页是standard也能正常清栈。4.2 防止登录页启动后被当成新操作的flag治理forceLogout()里调用了startActivity登录页一旦onResumeActivityLifecycleCallbacks.onActivityResumed会被触发。如果此时登录页不是LoginActivity的判断漏了或者你在Manager里没有保护那么新登录页算不算一次用户操作严格说跳转是系统行为不是用户主动操作所以不应该重置计时器。但实际上登录页onResume之后会有一个dispatchTouchEvent吗不会除非用户真点了。所以时间戳并不会被重置。真正的问题是如果登录页在长时间不操作后也启动了超时判断就会再次触发forceLogout()形成无限循环跳转。我的处理方式在登录页的判断之外再加一个SessionTimeoutManager.onTimeoutAction里的状态保护。SessionTimeoutManager.onTimeoutAction { if (currentActivity is LoginActivity) { // 已经在登录页不再继续跳转只清理业务缓存 loginRepository.clearLocalSession() returnset } forceLogout() }currentActivity可以在ActivityLifecycleCallbacks.onActivityResumed里维护。这样一个简单判断就把超时后跳转登录页的重复触发问题给堵死了。4.3 和BaseActivity方案对比全局管理更省心有的团队习惯在每个Activity的基类里放一段超时逻辑比如在onUserInteraction()里重置时间在onResume里注册Handler。这种做法的优点是简单直接但缺点是新来的同事如果不继承BaseActivity页面就会漏接超时。所有页面里都会堆一段业务无关的时效性代码不好维护。如果项目里有多个模块共用一个登录态每个模块各自实现一套超时逻辑后面想统一改阈值就要一个个翻。把超时管理放到Application层以后页面的改动量几乎为零。唯一需要业务配合的地方就是登录页无需注册计时以及触发超时时要清理本地登录态。而对这些在Manager里都可以统一处理。5. 前后台切换、锁屏、弹窗这些边界情况不处理必翻车5.1 后台停留时间要不要计入无操作时间这是一个经常被产品反复修改的点。我见过两种典型策略后台时间不计入用户切后台逛了一圈回来还能继续用只要前台停留时间没超就行。这种策略实现复杂要记录每次进出前后台的剩余时间还要在onStop/onStart里反复计算很容易出bug。后台时间计入不管用户在后台待了多久只要超过阈值回到前台就要求重新登录。金融类App大多采用这种因为用户离开一段时间后设备可能已经不在身边。我做的项目采用的是第二种原因很简单安全收益高且实现代价小。我们的时间戳方案天然支持后台时间计入因为后台没有应用内事件更新lastActiveTime用户回前台后checkTimeout()里一算发现早就超了直接踢出。如果你的产品非要切后台立即锁定连30秒都不给那可以再加一个监听用ProcessLifecycleOwner判断App进入STOPPED时记一个backgroundTime回到STARTED时判断是否立即锁。这里建议直接用androidx.lifecycle:lifecycle-process因为单纯用onActivityStopped判断前后台在多Activity切换时会误判。5.2 锁屏亮屏瞬间为什么容易被误判为有操作锁屏亮屏这个过程Activity本身收不到触摸事件。但在某些定制ROM上锁屏界面消失的瞬间系统可能会给当前Activity补发一个ACTION_DOWN或者发送一个KEYCODE_POWER的KeyEvent。如果不加过滤这个事件会被当成用户操作把lastActiveTime重置导致用户其实离开了一个小时但因为亮屏补发事件回来时没超时。针对这个问题我的建议是监听Intent.ACTION_SCREEN_OFF在息屏时记录一个screenOffTime。在recordUserAction()里如果screenOffTime不为空且当前时间距离息屏时间很近比如小于5秒就把这个事件过滤掉直到检测到用户真正点击了屏幕内部区域。使用KeyguardManager.isKeyguardLocked()在收到事件时判断锁屏是否还亮着如果锁屏未解锁所有事件不重置。这种细节不在需求里写清楚测试期很容易被漏掉。等到灰度用户反馈为什么我离开很久回来还没让我重新登录定位过程会非常痛苦。5.3 应用内Dialog和Fragment嵌套导致的计时混乱应用内Dialog是单独的一个Window它的回调不属于Activity。如果Dialog一直显示着用户点Dialog里的按钮Activity的Window收不到事件自然就不会重置。但用户在Dialog上的操作明明也是活人操作。解决方式有两种在DialogInterface.OnShowListener里给Dialog的Window设置一个触摸监听调用recordUserAction()。如果项目里所有Dialog都继承自一个BaseDialog就在BaseDialog里统一处理。还有一种情况是Fragment弹了个BottomSheetDialog之类触发了Activity的dispatchTouchEvent吗也不一定。总之如果你要做全局超时必须排查项目里的所有Dialog入口至少要把主要业务弹窗覆盖住。不然就会出现用户盯着一个弹窗看了10分钟被判定无操作踢了的怪事。6. 测试矩阵和灰度宁可多埋点不要凭感觉6.1 一份可以直接抄的测试用例表我自己在项目里常用下面这套测试用例分享出来给你参考。每个用例都对应一类线上可能翻车的场景编号场景预期行为T1打开App后不进行任何操作等待超过阈值先弹预警框倒计时后跳登录页T2在首页持续滑动/点击超过阈值不弹预警不跳登录页T3在EditText输入超过阈值后仅点击软键盘候选词如果按候选词不算操作策略应当弹预警需要确认业务接受T4退到桌面等待超过阈值后回到App应当跳登录页且返回键不能回到原页面T5息屏→亮屏→解锁不做其他操作应当按超时处理不能因为亮屏补事件被重置T6显示应用内Dialog超过阈值如果没做Dialog特殊处理应当超时并跳登录页但用户操作Dialog里的按钮应重置T7超时跳转登录页后登录页停留超过阈值登录页不做超时判断不跳转T8动态修改远程配置的超时阈值为15秒15秒后生效不需要重启App不误判6.2 通过adb模拟无操作和恢复操作手工测试时等5分钟太痛苦了。我一般会让开发阶段把超时阈值调成15秒或者用远程配置直接下发15秒来测。除了手工点还可以用adb命令模拟事件# 模拟一次触摸点击相当于用户操作 adb shell input tap 500 500 # 模拟一次滑动 adb shell input swipe 100 500 900 500 # 模拟按键比如按下音量键 adb shell input keyevent KEYCODE_VOLUME_UP # 模拟按电源键息屏 adb shell input keyevent KEYCODE_POWER注意input keyevent KEYCODE_POWER在息屏后部分机型会出现补发事件的情况正好可以用来复现T5那个坑。自动化层面用Espresso可以直接调用SessionTimeoutManager.recordUserAction()再直接调checkTimeout()来做单元测试比端到端手动测试快得多。关键是把checkTimeout()单独暴露成方法而不是藏在Runnable里写死。6.3 灰度策略超时阈值下放和快速回滚超时时间不是一个拍脑袋定的数字它需要在真实用户行为上验证。我的建议是初期用较宽松的阈值比如10分钟上线埋点统计实际超时退出人数/活跃用户数。如果比例过低比如低于0.5%说明大部分用户会被这个时间卡住可以逐步下调到5分钟、3分钟。如果收到大量我明明还在用却被踢了的反馈优先看有没有补发事件、后台时间计入、Dialog漏监听这三个问题而不是直接调阈值。远程配置一定要支持动态修改并且修改后要重置计时不然用户当前正在使用配置一下从5分钟变成30秒下一秒就把人踢了。我当时做线上灰度时用了一个名为会话超时踢出率的指标分母是当日活跃用户分子是触发超时登出的用户数。阈值调整后观察这个指标在3天内的变化同时关注登录接口的QPS有没有异常上涨以免是某个bug导致用户反复被踢。最后再分享一个小技巧超时前弹30秒倒计时预警框不只是为了体验还能大幅降低误杀带来的客诉。我接手这个需求时本来没打算做预警产品经理坚持要加结果灰度数据出来后我们都庆幸加了。如果你也在做android 无操作超时返回登录界面建议首次上线时把预警开关打开哪怕产品没要求也值得在技术方案里留出这个口子。