短视频刷久了,真正碍事的往往不是内容,而是压在画面上的那几层东西:顶部的标签栏、底部的导航、右边一竖排按钮、还有系统状态栏。它们各有各的用处,但在「我只是想看视频」的那几十秒里,全是噪音。
清抖(QingDou)是我给自己写的一个抖音界面减负模块(测试环境是 Android 版 40.7)。它不做去广告、不做下载,只干一件事:按播放状态,把界面收干净;一暂停,原样还回来。
这篇聊聊它的设计取舍、踩过的坑,以及最后沉淀下来的那点工程方法。
一、它到底改什么
核心不是「把某个栏调透明」,而是两套参数:
| 元素 | 播放时 | 暂停时 |
|---|---|---|
| 顶栏(标签栏) | 27% | 100% |
| 底栏(导航) | 27% | 100% |
| 右侧栏(头像/点赞/评论) | 31% | 100% |
| 状态栏 / 导航栏 | 隐藏 | 显示 |
数字可以自己拖,但结构是固定的:每一根栏都有「播放值」和「暂停值」两个数,运行时按播放器状态切换。这一点是它和「一键沉浸」类工具最大的区别——后者只有一个瞬时动作,而刷视频时状态是来回切的:暂停看评论、返回继续播、切后台再回来。只有把状态建模出来,界面才不会一惊一乍。
周边还有几个小能力,都是同一套管线的延伸:
- 每根栏的内容透明度和背景透明度分开调(一个是
View.setAlpha,一个是背景 Drawable 的 alpha,混在一起调会得到「字没了但底板还在」的怪效果); - 按关键词隐藏单个控件(比如把「商城」「朋友」从顶栏摘掉);
- 底栏整体上移几像素,避开手势条;
- 屏蔽双击点赞、长按菜单这类误触;
- 内容层还有一组「跳过」规则(广告 / 直播 / 商品卡 / 图文 / 低赞 / 超长视频),默认关着,需要自己开。
二、为什么做成模块,而不是改安装包
改 APK 的路子很直接:拆包、改资源、重打包、重签名。代价是每跟进一次宿主更新就要重来一遍,而且签名一变,登录态、支付、推送全得重新折腾。
模块走的是另一条路:在宿主进程启动时注入,改的是运行时对象,安装包本身一个字节都不动。好处很明显——随时能关,关掉重启就恢复原样,出问题不会把宿主搞坏。
代价也同样明显:你操作的每一个 View 都是别人的私有实现。没有稳定 id,没有对外契约,类名靠混淆、结构靠猜,一次改版就可能全盘失效。这个模块 90% 的工程量,其实都花在「怎么在别人的地盘上稳定地找到东西,并且证明自己找对了」。
三、三条设计原则
1. 定位:类名优先,结构兜底,用户兜底
找「顶栏」这件事,三种手段按可靠性排序:
- 类名/资源 id 命中——最准,但会被改版打碎;
- 结构特征——满宽、贴顶、高度在某个区间、可点击元素少,命中率一般,误伤率不低;
- 用户点选——让用户自己点一下「这就是顶栏」,记下来。
三层任意一层命中就能工作。听起来很稳,实际上第一层失效的时候,第二层会把设置页的列表行也认成「顶栏」,于是整页的选项开始忽明忽暗——这是后话。
2. 状态先于样式
模块里有一个显式的播放状态机:playing / paused / live。所有样式都是「状态 + 配置」的函数,而不是某个事件触发的副作用。
直播是个特例:直播永远在播,没有「暂停态」这个概念,所以它被单独钉成 playing。否则一进直播间,模块就按暂停态把一切恢复成 100%,看起来像「模块失效了」。
3. 能读数据,就别在视图层上划
最早做「跳过广告/直播」,思路是识别出来之后注入一次上滑手势。这条路能走,但很脏:
- 合成手势要跟宿主的触摸状态机抢事件,容易撞出「屏幕抖动」「划不动」;
- 图文、直播卡没有视频画布,视图层判据一堆例外;
- 判错了就是「把用户正在看的东西划走」,体感极差。
后来改成数据层过滤:挂住列表数据模型(FeedItemList 那一层)的 getItems(),在数据进入渲染之前把命中的条目摘掉。视图层只在「拿不到数据」时兜底。
有意思的是,getItems() 在 20 秒里被调了 391 次。也就是说判定必须足够便宜——不能每次都解析 URL、反射字段。最后的做法是:判定逻辑只读几个已缓存字段,反射访问器用 ClassValue 缓存,结果按「列表实例 + 长度 + 配置代数」缓存。一个「看起来只是过滤器」的功能,真正的难点全在性能上。
四、逆向:拆了什么,拿到了什么
先把对象说清楚,不绕弯子。
4.1 拆了两类东西
| 对象 | 具体拆的是什么 | 拿到什么 |
|---|---|---|
| 抖音(闭源) | 安装包本身:清单 + 55 个 dex | 组件结构、容器类名、数据模型的边界 |
| 抖仙人 | 完整源码 | 全量实现细节 |
| Doula | 未加壳的包,可反编译 | 真实现(类、方法、字段都在) |
| YuePanel | 带 native 保护 | 只有架构轮廓,具体算法看不到 |
抖音是主力:这个模块 90% 的判据都来自它的类名和数据结构。另外三个项目只用来看「这条路有没有人走通」。
4.2 从抖音拿到了五样东西
① 启动组件是个 alias —— 这条直接解释了「所有功能一起失效」
aapt2 dump xmltree 读清单,看到的是:
<activity-alias name="com.ss.android.ugc.aweme.splash.SplashActivity"
android:targetActivity="com.ss.android.ugc.aweme.main.MainActivity" />
清单里的组件名和运行时类名是两个不同的字符串。我原来只比 SplashActivity,真机上拿到的是 MainActivity,条件永远不成立——于是每一帧都在「这不是信息流页面」的分支里把样式清掉。同一段写死的名字还在内容过滤里,那条链路也一起哑了。
② 三个容器的类名跨版本没变 —— 这是「类名优先」的底气
顶栏容器、底栏容器、右侧栏容器,类名在多个版本里都稳定。所以第一层判据可以直接吃类名,不必全靠「满宽、贴顶、等高」这种结构特征去猜。猜结构的代价是误伤:设置页的列表行也满足那些几何条件。
③ 列表数据的边界在哪 —— 数据层过滤的入口
列表模型上有七个可挂的方法:getItems / getItemsP / getItemsNotNull / getPreloadAds(读)和 setItems / setItemsP / setPreloadAds(写)。读方法改返回值,写方法改入参,两边都能摘条目。
顺带知道了一件关键的事:反序列化是直接写字段、绕过 setter 的。所以只在读方法上过滤不够——getItems() 每次都从原始列表拿新数据,不缓存就会重复计数(一条直播被算成 111 条)。最后的缓存键是「列表实例 + 长度 + 配置代数」。
④ 两个字段语义 —— 直接决定两个规则能不能做
- 作品时长是
Integer,单位毫秒。我一开始当秒用,61 秒的视频被判成 61 分钟,开了「跳过超长视频」等于「跳过一切」; - 点赞数不在作品对象上,在它的
statistics里,而且混淆后字段名是单字母。这一条不通,「低赞过滤」根本无从下手。
⑤ 视图位置是每帧被写回的 —— 这条是从行为反推的
抖音每个绘制周期都会把自己的 transform 写回去。所以外部设的 translationY 只能活到下一帧,最后只剩「点击判定区被挪走」这一个副作用。这不是从某一行代码读出来的,是三个来源(模块转储、uiautomator、截图像素)对不上之后,反推出来的结论。
4.3 从同类工具拿到了两样东西
① 顶栏位移没人做,这不是巧合
翻 Doula 的位移模块 NavigationInsets,409 行:字段是 managed / lifts / padded / offsetDp,钩在 onAttachedToWindow 和 dispatchApplyWindowInsets 上,写 translationY 时用 0.5 的阈值去重。写得很讲究——但往下读就会发现,它只抬底栏,日志里写的是「Safe bottom inset」,纯粹为了避开手势条。
这直接支撑了我后来的决定:顶栏那套 translation / 负 margin 试完就删。不是我姿势不对,是这条路本来就走不通。
② 一张能力对照表 —— 决定「不做什么」
把抖仙人、Doula、YuePanel 的能力列成矩阵:谁做了顶栏透明度、谁做了播放/暂停双参数、谁做了系统栏隐藏、谁做了底栏上移、谁做了双击屏蔽…… 矩阵里空着的那几格,比填满的更有用——它告诉你哪些功能是「没人做成过」,哪些是「做成了但代价很大」。
4.4 怎么拆的
方法本身不新鲜:
aapt2 dump xmltree读清单(就是这条挖出了 alias);- dex 反汇编成文本,按类名、方法名、字符串常量搜;抖音的 55 个 dex 全过了一遍;
- 混淆过的字段(
d、e这种单字母)靠「调用点 + 类型」反推,别指望名字; - 拿不准的写一个小测试模块,在真机上打日志验证——逆向给的是假设,真机才是判据。
4.5 边界
- 只读:不改抖音、不重打包、不重签名;
- 单机:只在自己的设备上验证;
- 反汇编产物只在本地当「地图」翻,不进仓库、不对外传播;
- 文章里只写结论(类名、方法边界、行为描述),不贴宿主的任何代码。
也正因为涉及对闭源软件的反向分析,这篇文章不提供下载链接、安装包,也不提供任何逆向产物,只分享思路和结论。想要类似效果,建议按公开的 Android / LSPosed 文档从头实现——路径本身不神秘,难的是那些坑。
五、六个坑,一个比一个贵
5.1 清单里叫 A,运行时叫 B
某次重构之后,用户反馈「所有功能一起失效」:配置正常、注入正常、自检里所有容器都定位到了,但「被模块接管的视图数」是 0。
根因在宿主的清单里:
<activity-alias name="com.example.host.splash.SplashActivity"
android:targetActivity="com.example.host.main.MainActivity" />
启动组件是个 activity-alias。于是「清单里的组件名」和「运行时的类名」是两个完全不同的字符串:
dumpsys/am start看到的是SplashActivity;activity.getClass().getSimpleName()拿到的是MainActivity。
我当时写的是「只接管 SplashActivity」,这个条件在真机上永远不成立,于是每一帧都走「这不是信息流页面」的分支,把已经设好的样式全部清掉——表现就和「总开关关掉」一模一样。
更阴的是:同一段写死的名字还出现在内容过滤的一票否决里,那条链路也一起哑了,只是没人发现。
教训:判定宿主这类「身份」问题,必须同时接受多个名字(运行时类名、清单组件名),并且认不出来要报错,不能静默。现在自检里有一行专门盯这个,认不出直接红。
5.2 差一个状态栏的坐标
有一整周,我反复判定「点了没反应」,最后发现是三套坐标混着用:
| 来源 | 坐标系 | 同一个顶栏 |
|---|---|---|
| 模块自己的视图树转储 | 窗口坐标 | @0,128 |
uiautomator dump | 屏幕坐标 | [0,256][165,421] |
adb shell input tap | 屏幕坐标 | 同上 |
状态栏还在时,窗口从 y=128 开始,两套坐标恒差一个状态栏高度。拿转储里的坐标去 input tap,会稳定偏 128 像素——正好落在「三横杠」和下面信息流的交界处,于是点菜单打开了别人的主页。
教训:报坐标必须注明坐标系;判断「点得准不准」用 uiautomator(屏幕坐标),判断「我们有没有写进去」才用转储。
5.3 顶栏位移:做了三个月,删掉只花十分钟
想做「顶栏贴顶」:隐藏状态栏之后,系统仍然保留那段内边距,顶栏会低一截,看着别扭。
第一版用 setTranslationY(-128)。转储显示成功:顶栏的 geometry 从 @0,128 变成 @0,0。于是我以为成了。
直到用户反馈「返回箭头点不动,点三横杠会跳去别人主页」。三个来源一对,问题现形:
| 来源 | 顶栏位置 |
|---|---|
| 模块转储(窗口坐标) | @0,0,高 165 |
uiautomator(屏幕坐标) | [0,256][165,421] |
| 截图逐行扫描(像素) | 文字仍在 185~240 行 |
View 的 geometry 改了,画面没改。 宿主每个绘制周期都会把自己的 transform 写回去,我们的 translationY 最后只剩一个副作用:把点击判定区挪走了。于是三横杠画在 y≈338,判定区却在 y≈210,用户点在 338——落空,事件穿透到下面的信息流。
试过第二版:改「状态栏占位条」的高度,属于真布局变化。转储抓到高度确实变成 0、顶栏也到了 0..165,但系统侧仍是 128..293——宿主按 WindowInsets 每个布局周期重算一次,修改只能活一帧。
再往下就只剩「hook WindowInsets 伪造 inset」一条路,收益和风险不成比例。
结论:删掉。 顶栏回到宿主自己的位置,点击坐标永远和画面一致。顺带发现同类工具里做「上下位移」的那一家,也只做底栏、不做顶栏——取舍一致。
这件事留下的不变量是:自检里有一行「顶栏位移 = 0」,以后谁再加位移,测试直接红。
5.4 同一个 Activity 里,还有别的页面
「消息」页又开始自己往下滑了。查日志,一秒一条:
闸门:act=...main.MainActivity page=FEED tab=消息 surface=false
内容过滤跳过:直播(视图层识别)
三个条件同时成立才会滑:
- 消息页和视频流在同一个 Activity 里(
topResumedActivity始终是宿主主界面),所以「按宿主 Activity 判页面」恒为真; - 页面类型靠扫类名判断,而消息页的视图树里残留着视频流的容器,于是也被判成「信息流」;
- 消息列表里有「直播中」的徽标,被直播卡判据命中,于是每 900 毫秒注入一次上滑。
修的时候走了两条弯路,都值得记下来:
- 按命中点判断:取屏幕 62% 高度那一点,看最上层是谁。结果首页也判成「不是信息流」——那一点命中的是「全屏观看」之类的浮层。
- 按最上层控件判断:逐层做 z 序命中测试。结果消息页反而判成「是信息流」——
getGlobalVisibleRect只认矩形、不认 z 轴,命中的是叠在背景上的视频流容器。
最后的判据是底栏 tab 的选中态:宿主只给当前选中的 tab 打 selected=true,而且只有底栏用这个语义。取它的文字(跳过未读角标的纯数字),就能知道「当前是不是首页」。
判据演进一次看全:
| 判据 | 能区分「首页 / 消息」吗 | 原因 |
|---|---|---|
| 宿主 Activity 类名 | 否 | 两个页面同宿主 |
| 扫类名判页型 | 否 | 消息页残留视频流容器 |
| 命中测试 | 否 | 不认 z 轴,命中背景 |
底栏 tab 的 selected | 是 | 宿主只给当前 tab 打标记 |
教训:判据必须带「当前选中了什么」的语义。纯几何、纯类名的判据,在多页共存的容器里一定会翻车。
5.5 hook 的是方法,不是实例
有用户反馈:图文详情点「展开」之后白屏,必须向左滑一下才看得到内容。
根因只有一行:
hook(method) 挂的是方法,不是实例。
翻页器继承自 androidx.viewpager.widget.ViewPager,而基类的 setCurrentItem / setCurrentItemInternal 一并被挂上了——于是进程里所有 ViewPager 都被拦,包括图文详情自己的翻页器。它本来要跳到第 2 页,却被我们的「顶栏标签模型」改成了信息流当前的页码,界面就白在那里。
修法是拦截器第一行加实例校验,不是自己的分页器直接放行:
if (chain.getThisObject() != pager) {
return chain.proceed();
}
教训:钩子写在基类方法上时,影响的是一整个类族的实例。凡是拦截,先问「这是不是我管的那个对象」。
5.6 你改的配置,可能不是进程在用的那份
功能明明在设置里关了,行为却照旧。查配置文件,值确实是 false;查进程日志,过滤还在跑。
原因是配置重载在「读取通道不可达」时直接 return,静默沿用进程启动时读到的旧值。于是:
- 配置文件说的是关;
- 进程里跑的是开;
- 界面上的开关看起来也生效了(写进去了)。
教训:判断「某个功能有没有开」,不能只看配置文件,要看进程日志里有没有对应的输出。改完开关最稳的是重启一次宿主。
六、让「能用」这件事可自证
这类工具最怕的不是报错,而是静默地不工作——界面看着正常,其实一个功能都没生效。所以后半程我花在「自证」上的时间,比花在功能上的还多。
自检(27 项):一条广播触发,覆盖配置链路、注入、容器定位、读回(每根栏的实际透明度 vs 目标)、系统栏可见性、播放状态机、内容层、列表级过滤。结果写进一个文本文件,手机上看不了日志也能读。
它救过很多次场。比如「所有功能失效」那次,自检显示「容器全部定位到,但托管视图 = 0」——这一条直接把方向从「宿主改版」扭到了「我们自己没写」。
单元测试(149 例):只测纯逻辑——过滤判定、配置迁移、样式作用域、别名场景。所有要碰 Handler、Log、View 的代码都不进单测,因为那些在 JVM 里是桩,测了也是自欺欺人。
六个调试广播:转储视图树、切顶栏标签、一键停用引擎、切位移方式、跑自检、清日志。它们不是给用户用的,是给我在真机上做二分用的。尤其「一键停用」——停用后现象还在,就说明不是模块的锅,能省掉半天。
三份文档:变更记录写「为什么这么改」(每条都带当时的证据)、验证手册写「怎么验、看哪几个数」、调试手册写「常见日志怎么读」。这三份东西的价值在于:三个月后的我,能凭它们重新理解当时的取舍。
七、一些数字
- 版本从 0.1 迭代到 0.72,72 个可回滚的构建,每个都留了 release / debug 两份包和校验和;
- release 包约 240 KB,debug 约 2.8 MB——功能都在 hook 层,没有资源负担;
- 宿主安装包里拆出 55 个 dex;另外拆包对照了三家同类工具(见第四节);
- 单测 149 例,自检 27 项,调试广播 6 个。
八、结语:这类工具的价值在「克制」
写到现在,最大的体会不是「hook 怎么写」,而是什么时候不该写。
顶栏位移那件事,我花了三个月做出来、又花十分钟删掉。删掉之后,点击错位、缓慢上移、菜单跳转三个问题一起消失。一个做不到可靠实现的功能,最好的处理方式是承认它做不到,而不是加一层补丁让它看起来像做到了。
第二个体会是:判据要带状态语义。类名会变、几何会被遮挡、getGlobalVisibleRect 不认 z 轴——这些判据都是「静态快照」。而界面是有状态的:谁被选中、谁在最上层、现在是在播还是暂停。能表达状态的判据,才经得起改版。
第三个是:给自己写工具,也要留证据。自检、单测、日志、变更记录,这些东西在「能用就行」的阶段看起来是负担,但它们是唯一能让你在半年后还敢改代码的东西。
毕竟这类项目的生命周期,从来不是由功能多少决定的,而是由「你还敢不敢动它」决定的。
九、致谢
这个模块能走到今天,有一半的判断是从别人的成果里学来的,这里明说:
- 抖仙人、Doula、YuePanel:三个同类项目的作者。Doula 的
NavigationInsets让我确认了「顶栏位移没人做成过」;抖仙人把完整实现放了出来,很多入口是从里面读到的;YuePanel 加了 native 保护,但它的架构仍然给了参照。这三个项目与清抖没有隶属或授权关系,本文只做技术讨论与致谢,不提供它们的代码或产物。 - LSPosed / Xposed 生态:模块能做到「随时关掉、重启恢复」,靠的就是这套框架。没有它,这类工具只能走改包那条路。
- AOSP:
WindowInsets、View 的绘制与命中测试、无障碍树的行为,最后都得回源码确认——文档里含糊的地方,全靠它。 - 还有那些把踩坑过程写进 issue、博客和拆包笔记的人。很多「这条路不通」的结论,都是别人先交过学费的。
说明:本文只分享思路与结论。文中涉及对闭源软件的反向分析,因此不提供安装包、下载链接,也不提供任何反汇编产物或工具配置;相关内容仅用于在自己的设备上做界面偏好调整。
发表回复