ARTICLE · 1030300
手机 APP 突然"冻住"点不动?它没崩,只是被堵住了
你有没有过这种经历:正刷着手机,突然屏幕就"冻"住了。点按钮没反应,滑动没动静,过了几秒还弹出一个对话框:“应用未响应”,问你"等待"还是"关闭"。
你第一反应多半是:这 APP 是不是崩了?
其实不一定。它可能还活着,只是"卡住不动"了。在鸿蒙 HarmonyOS 里,这类问题有个专门的名字,叫应用冻屏(APP_FREEZE)。今天我们就用大白话聊聊:手机到底是怎么"冻"住的,又是谁把它"冻"住的。
一、先分清:崩了,还是冻了?
崩溃和冻屏,是两码事。
崩溃,是进程死了。就像一个人突然倒下,彻底没气了。系统会立刻处理,给你弹个"应用已停止"。
冻屏,是进程还活着,但不干活了。就像一个人睁着眼坐在那儿,你喊他、推他,他都没反应,可他还喘着气。界面卡死、点不动,就是这种状态。
冻屏还分两种,治法完全不同:
阻塞型:主线程被"堵住"了,卡在原地动不了。等锁、等跨进程调用、等输入输出,都属于这一类。 繁忙型:主线程没被堵,而是"忙不过来",任务太多太重,做不完。
怎么区分?靠一把"金钥匙"——3 秒/6 秒栈顶对比。这个后面细说。
二、系统怎么知道应用"冻"住了?三个"监工"
要分析冻屏,先得懂系统怎么判定冻屏。鸿蒙里有三种判定机制,相当于三个"监工"。
1. THREAD_BLOCK_6S:看门狗判活
每个应用进程里,系统都安插了一个"监工"线程,叫 watchdog(看门狗)。它干一件事:每隔一段时间,往主线程的任务队列里塞一个小任务,相当于问一句"你还活着吗?"
主线程正常的话,会顺手把这个小任务做掉。可要是主线程被堵住了,这个小任务就没人理。
超过 3 秒没执行,看门狗上报一次告警,抓一张"现场照片"(warning 栈)。 超过 6 秒还没执行,上报故障,再抓一张照片(error 栈)。
两张照片一对,就生成了完整的冻屏日志。
2. APP_INPUT_BLOCK:输入回执超时
你点了一下屏幕,输入系统把点击事件发给应用,应用得回个"收到"。如果这个回执超时了——5 秒(新版系统放宽到 8 秒)——系统就判定故障。因为事件能发到应用,所以这类故障一定发生在前台。
3. LIFECYCLE_TIMEOUT:生命周期切换超时
应用在切换状态(比如从后台切到前台)时,系统会发指令,等应用回应。等太久没回应,就报超时。加载阶段给 10 秒,切前台给 5 秒。
三种机制的检测时长,汇总成一张表:
小知识:用 DevEco 的 Debug 按钮启动应用时,系统会自动关掉这些超时检测,免得调试干扰。所以调测冻屏要用 Run 方式。
三、金钥匙:3 秒/6 秒栈顶对比
这是整个排查里最关键的一步。
系统在 3 秒和 6 秒各抓了一次主线程的"现场照片"(栈)。把两张照片的栈顶对比一下:
两张照片一样 → 主线程卡住没动,是阻塞型。它要么在等锁,要么在等跨进程调用,要么卡在某个业务调用上。 两张照片不一样 → 主线程还在跑,只是任务太重做不完,是繁忙型。
一句话记忆:“一致堵,不一致忙。”
先分型,再下药。阻塞型去追"被谁堵住了",繁忙型去找"哪里太忙了"。
四、阻塞型三种形态:看栈顶一眼认出
阻塞型冻屏,主线程卡在原地,具体卡在哪,看栈顶就能认出来。三种形态:
| 等锁卡死 | Mutex::Lock(),往下是数据库连接池、ResultSet 关闭 | |
| IPC 卡死 | ioctl,往下是 Binder 写入、等待完成 | |
| 业务栈帧 | pread,往下是数据库读页 |
这里有个判读要领:栈顶是系统函数并不可怕,关键是往下找到第一个业务代码帧——它回答"主线程在替谁等"。
形态一:等锁,找"拿着钥匙的人"
锁(lock)可以理解成一把"钥匙"。主线程想用某个资源,得先拿到钥匙;钥匙被别人拿着,它就只能干等。
排查路径四步:
确认主线程栈顶在 lock/wait,确实在等锁。顺着栈往下,找到这把锁属于哪个模块(数据库连接池、单例对象、全局缓存)。 翻全量线程栈,找哪个线程正拿着这把锁干活,它还卡在别的什么事上。 还原等待链:A 等 B 的锁,B 又在等 C……链的末端就是根因。
典型的死锁长相:3 秒/6 秒栈相同,主线程在 wait,另一个线程持锁不释放,而那个线程又在等主线程手里的东西——互相等,谁都动不了。
修复方向:减小锁的粒度(别抱着锁做 IO 和网络)、统一加锁顺序、能不用锁就不用锁。
形态二:Binder,看"对端进程"
Binder 是鸿蒙里进程间通信的机制,可以理解成进程之间"打电话"。主线程打了一个电话出去,对方一直不接,它就卡在电话旁干等。
排查看两个东西:
BinderCatcher:进程间通信的"通话记录",格式是"谁 to 谁,等了多久"。等超过几秒的对端,就是重点怀疑对象。 PeerBinder Stacktrace:对端进程的栈。你在等它,它在干什么,一目了然。
还有个概念叫 Binder 线程池:每个进程最多 16 个线程专门处理跨进程调用。同步调用会占着线程等对端返回,对端慢,线程就被占住;16 个全占满,后续调用全部排队,表现为大面积卡顿。这就是"级联阻塞"。
形态三:业务栈帧,重 IO 挪子线程
栈顶是 pread(读文件)、数据库读写这类,说明主线程在做重 IO。主线程本该轻装上阵,结果扛着大石头跑,自然跑不动。优化方向:把重 IO、重计算挪到子线程。
五、排查五步走:别一上来就追凶手
分析冻屏,顺序很重要,不能乱。官方总结了一套五步闭环:
先排整机假象:先看是不是整机的问题(低内存、CPU 打满、过热降频)。整机问题会让应用"躺枪",先排除误判再谈分析。 划窗口:看 Reason 和前后台,推出检测时长;用上报时间反推故障发生的时间区间,只看这个窗口的日志。 分类型:3 秒/6 秒栈顶对比,一致是阻塞型,不一致是繁忙型。 追链条:等锁找持锁线程,Binder 看对端,业务调用找第一个业务栈帧。 定根因:在任务队列里找超时任务,定位到具体业务代码,修复。
为什么第一步是排整机?因为从 API 20 起,整机资源告警时,日志里会直接输出一行 NOTE,大意是"这个故障可能是低内存或热限频引起的,可以忽略"。系统都提醒你了,就别再冤枉应用了。
六、三板斧:栈、trace、日志
当栈顶落在方舟运行时(JS 代码都在里面跑)时,栈顶看不出业务,官方总结出"三板斧":
一板斧,看 freeze 栈:栈顶代码行告诉你 3 秒/6 秒在跑什么;栈相同查死锁;留意典型长耗时函数。 二板斧,看 trace:瞬时栈只有 3 秒/6 秒两张照片,trace 能还原冻屏前整个 6 秒的进程状态,相当于把"主线程这 6 秒干了什么"拍成电影。 三板斧,看日志:栈和 trace 都定不了界时,看 freeze 前的日志。日志里反复出现的接口名,往往就是凶手签名。
七、两个真实案例
案例一:3 秒/6 秒栈相同,不一定是死锁
某应用冻屏,3 秒和 6 秒的栈完全一致,栈顶都是同一个引擎接口。剥掉引擎栈帧,两次抓到的应用代码行都是同一行。
按前面的判据,栈一致应该是"堵住了"。但仔细一看,这个接口流程无锁,排除死锁。那真相是什么?
同栈不一定是在"等",也可能是在"算"。 3 秒里主线程一直在执行同一行代码,典型的大 for 循环——循环遍历查找,数据量一大,循环耗时过长。
官方的规避方案很有意思:给循环装一个"超时保险丝"。用 setTimeout 设一个 100 毫秒的定时器,到点就把一个标志位置真;循环每轮检查这个标志,超时直接跳出,保住主线程。根治方向是优化查找算法,或整体挪到子线程。
案例二:栈和 trace 都失灵,日志兜底
另一个服务类进程卡死,trace 显示 GC 循环调用,但每次占比不高、符合规律,排除 GC;freeze 栈也无法定界。两板斧都失效了。
第三板斧上场:按线程加时间窗口过滤日志。发现应用一直在循环调用某个搜索接口,日志反复刷。与应用方确认,就是循环调用导致的。
这课教的是"退路":不要在一棵树上吊死。 栈不行换 trace,trace 不行换日志,三招组合,覆盖绝大多数场景。
八、给普通用户的几点提醒
如果你是普通用户,遇到"应用未响应",可以记住这几点:
别急着卸载。冻屏不一定是应用"坏"了,可能是整机内存不足、手机过热降频,甚至系统调度问题。先重启试试,往往就好了。 冻屏久了,系统会"杀"掉应用保流畅。所以冻屏不只是体验问题,还可能让应用被误杀。 后台应用也会冻屏,只是系统放宽了检测时长(21 秒),因为后台不直接和你交互。
如果你是开发者,记住五条军规:
拿到冻屏日志,先看 NOTE 行和整机资源,再开始分析。 3 秒/6 秒栈对比先行:一致追链条,不一致查繁忙,别混着治。 等锁必找持锁方,Binder 必看对端——凶手常在别的线程、别的进程。 主线程三不放:重 IO、重计算、同步跨进程调用。 线上靠 uuid 聚类看冻屏构成,别一条条翻原始日志。