好了,经过上一篇文章《Android App 电量统计原理与优化》的分析,相信你已经完全掌握了 Android App 电量的计算方式,现在可以开始给自己的 App 开发电量异常检测功能了。如果觉得麻烦的话可以先尝试一下我们的开源方案:Matrix 已经实现了类似的功能 —— BatteryCanary,并且在我们的项目上全量稳定运行了一年多,帮我们发现了不少新增 & 隐藏多年的电量问题。
跟其他 APM 指标优化框架一样,电量优化框架 BatteryCanary 也是作为一个相对独立的 Plugin 功能集成在 Matrix 框架里面。不过电量相关的问题比较复杂,一般来说,比较“客观”的异常检测(比如 Crash 或者 ANR),都能做到“开箱即用”(out-of-box);而像卡顿反馈(没有 ANR 弹窗但用户感觉卡顿)这类比较“主观”的异常检测,就需要做一些额外的自定义配置了。
电量异常的判断标准则要比卡顿问题“主观更多”,而且导致耗电的原因更是多种多样,比如线程问题、WakeLock 问题、Wifi/蓝牙/GPS 扫描频繁问题等等。相应的 BatteryCanary 也开发了许多针对以上种种问题的功能模块,从而导致 BatteryCanary 的设计相比其他 APM 框架繁琐了不少,使用前需要根据自己 App 需要指定启用哪些模块以及相应的自定义配置。
如果你只想使用 BatteryCanary 的核心功能,那么只需要执行一下简单的初始化即可:
1 | public static BatteryMonitorPlugin createMonitor(Context context) { |
如果你对 BatteryCanary 的全部功能感兴趣,那么你可以尝试使用以下复杂的初始化配置,并自己尝试折腾一下每个功能的自定义参数配置(一般情况下使用默认配置即可):
1 | public static BatteryMonitorPlugin createMonitor(Context context) { |
Crash / ANR 等常见的异常,可以通过捕获系统、App 相关的异常信号或者监听回调来判断异常是否发生,而相比之下耗电异常的检测就要麻烦许多。前者一般是瞬时的状态,在异常发生的时候捕获一下异常并 Dump 出异常现场信息(StackTrace、前后台等信息)后就能比较有效地分析和修复问题;后者则是一段时间内发生的事情,比如 App 使用十几分钟后发现这段时间内耗电比较严重…… 所以检测 App 电量异常,实际上就是监控 App 某一段时间内(结束时间的电量数据 - 起始时间的电量数据)电量的统计数据有没有出现问题。
为了方便计算统计数据 Diff,BatteryCanary 使用一个组合器(CompositeMonitors)来存放每一个电量监控模块(Monitor)的统计数据,封装了比较繁琐的数据 Diff 的计算代码,使用的时候只需要在开始监控的地方调用一下 CompositeMonitors#start()
并在结束的地方调用一下 CompositeMonitors#finish()
,即可获得这段时间内的电量统计信息,然后可以根据自身 App 的指标需要,判断出哪些电量监控模块出否出现异常。
这里需要特别强调的是,正因为耗电检测是一个过程,所以每一个过程(或者说是使用场景)对应的时间窗口是可能出现重叠的。我们可以根据徐存,给每一个使用场景创建一个不同的 CompositeMonitors 来监控(不同场景通过 Scope 来区分)。
例如,以下代码实现了 App 不同使用场景下的电量监控:
1 | // 1. 后台电量监控 |
目前我们的项目主要使用 BatteryCanary 来检测是否出现线程功耗异常和系统服务调用异常(不符合隐私合规,或者频繁调用)。
线程的监控模块主要可以分为一般线程(Java + Native)和线程池线程(Executor + HandlerThread + 其他用来执行多种 Runnable 的线程)。上一篇文章我们提到,一般线程我们可以用线程名来聚合问题,而线程池则需要使用 Runnable 名来聚合问题。
首先记得在 BatteryCanary 初始化的时候开启线程 & 线程池的监控功能:
1 | BatteryMonitorConfig batteryConfig = new BatteryMonitorConfig.Builder() |
然后就可以开始愉快地玩耍了。
判断一个线程功耗是否出现异常需要先了解一下 Linux Jiffy 的概念,忘了的同学可以先复习一下上节课的内容:线程监控。我们通过线程的 Jiffiy 消耗以及线程的运行状态,就可以推断出当前线程在这段时间内有没有异常。
1 | Delta<JiffiesSnapshot> delta = compositor.getDelta(JiffiesSnapshot.class); |
一般来说,当线程的 Jiffy 开销约等于 6000 jiffies/分钟的时候,就说线程在这段时间内一直在吃 CPU 资源(CPU 中断),换算下来线程的 CPU Load 约为 100%(App 的 CPU Load 为所有线程的 CPU Load 累加,具体值的区间在 [0, 100 × CPU Core Num])。当 App 在后台的时候,线程持续这种状态超过 10 分钟以上,就说明已经出现待机功耗异常;当时间超过 30min 甚至 1 hour 以上,就可以在系统电量统计排行上面看到明显的电量曲线的变化。
以上线程异常的规则定义可以根据自身 App 需要灵活定义,不过我们实践发现,如果长时间的后台线程 Jiffies 推算出来的 CPU Load 一直在 100% ,并且该线程没有明显的 Log 输入,则说明当前线程很可能出现了死循环(一般是 Bug 导致,而且陈年老代码里更容易出现);而如果有大量相关的 Log 输出,则说明该线程相关的业务功能在 App 进入待机状态后,可能漏了执行相应的退出和析构逻辑(一般在新开发而没经过测试 & 先上考验的新代码里容易出现)。
线程池的 Jiffy 开销异常于普通线程的统计方式不太一样,需要把线程池整体的 Jiffy 消耗分摊到每个 Runnable 上面,而且考虑到同一个 Runable 会反复被执行,还需要把相同 Runnable 的 Jiffy 累计起来。
因为每个 App 项目自身的 ThreadPool 库大相径庭,所以 BatteryCanary 并没有直接集成具体的线程池监控模块,而是提供一个基类(AbsTaskMonitorFeature),具体项目需要根据自身实际情况做一下扩展。以下是一个简单的线程池监控拓展样例:
1 | public final class ThreadPoolJiffiesMonitor extends AbsTaskMonitorFeature { |
与普通线程的异常定义类似,线程池只需要计算每一个 Runnable 的 CPU Load 就能检测出该 Runnable 有没有出现功耗异常。
1 | // 1. 获取当前线程池所有 Runnable 的 CPU 功耗 |
一般的线程问题,可以直接通过线程名看出具体是 App 的哪个业务模块出现问题了(复杂点的需要配合线程 StackTrace 来聚合问题)。在这种情形下,“野线程”(没有设置名字的线程)的问题就比较难处理了。
尽管在代码规范上,我们一直强调不要随意 new Thread()
以及创建新线程一定要设置强业务相关的线程名,不过我们在治理线程功耗上面还是经常能看到一些新增的“Thread-xxx”,经过仔细排查之后我们有了意外的收获:
Native 线程回调 Java 代码会导致线程名被重置成 Thread-xxx!
主要原因是 Native Call Java 的时候,需要先执行一下 AttachCurrentThread,这个调用如果没有显示指定当前线程名的话,就会导致线程名被重置。具体源码可以参考 thread.cc,其调用链如下:
1 | JVM->AttachCurrentThread(&env, nullptr); |
解决方案也非常简单,记得指定线程名或者使用另一个相同功能的 API:
1 | JavaVMAttachArgs args; |
通过以上手段可以有效地检测 App 哪些线程出现了异常,而在具体生产项目的应用上,检测哪些线程出现异常往往还不够(只有线程名、Jiffy & CPU Load),更重要的是要检测线程在哪些代码上出现了异常,这里就需要想办法采集到 Android 的线程堆栈信息(包括 native + Java)。
实际上,除了线程名 & Runnable 之外,线程堆栈也是分析和聚合耗电问题的一项关键的数据。不过线程堆栈采集(StackTrace Collect)主要用到的 unwind 技术是一个比较成熟的课题,包括电量优化在内的大部分 APM 优化项目都或多或少需要用到该技术,这里就不做过多展开。简单说一下我们方案大致的思路:通过 libunwindstack 来采集指定 tid 线程的 native 堆栈 & 通过 tgkill 来获取指定 Java 线程的 Thread 引用从而获取 Java 堆栈(具体应用上,我们跟倾向于优先采集 Java 线程堆栈,因为 native 堆栈的干扰数据太多,不太容易聚合问题)。
电量优化另一个比较重要的场景是跟系统服务调用相关的,主要包括 WakeLock/Alarm 的请求,以及 Wifi/BlueTooth/GPS 等需要执行硬件扫描的功能的调用。
一方面,以上服务都是 Android 系统电量统计中权重占比比较高的模块,而且 Google Android Vitals 里功耗相关的适配指导也比较注重这几个模块的内容。另一方面,除了 Vitals 里定义的异常指标之外,国产手机的 OEM 系统也针对性地加入了针对以上模块使用情况的合规 & 功耗异常告警,这些告警给我们项目增加了许多 App 耗电异常的用户反馈,因此我们也非常重视相关系统服务调用的使用情况。
考虑到稳定性问题,BatteryCanary 主要通过 Hook 以及 ASM 两种方案来实现 App 系统服务调用的监控:Hook 方案接入简单但是在新系统以及个别设备上稳定性较差,比较适合采样监控;ASM 方案兼容性好适合现网全量启用,但是需要配合 ASM 插桩框架使用(每个项目使用的插桩框架不同,为了避免插桩冲突 Matrix 并没有提供内建的插桩功能),而且无法覆盖到动态代码(插桩工作主要在编译环节执行)。
Hook 方案的接入方式最简单,只需要在初始化 BatteryCanary 的时候启用 AMS Hook 配置即可:
1 | public static BatteryMonitorPlugin createMonitor(Context context) { |
通过以下代码可以从 CompositeMonitors 中获取 GPS 定位服务调用的统计信息(其他模块的访问方式类似):
1 | Delta<LocationSnapshot> delta = compositor.getDelta(LocationSnapshot.class); |
为了避免数据重复,ASM 方案和 Hook 方案只能同时启用一个。在关闭 Hook 功能的时候,需要通过 ASM 框架针对相关的系统服务调用进行插桩,并通过以下方式将插桩数据整合到 BatteryCanary 框架内,这里还是以 GPS 为例:
1 | public void asmOnLocationScan() { |
上面提到,耗电异常是指某一段内发生的异常,因此耗电异常时候的 App 和 Device 状态也必须统计的是一段时间内状态,具体讲就是这端事件内状态的切换细节以及每个状态的时间除以整个时间窗口的占比。
这部分状态的统计工作主要是由 AppStatMonitor 和 DeviceStatMonitor 这两个监控模块完成的。其中 AppStatMonitor 负责监控 App 前台、后台、ForegroundService 以及浮窗 4 种状态的变化,而 DeviceStatMonitor 则负责设备充电、未充电、息屏、Doze(低电耗模式)、App Standby(应用待机模式)这 5 种状态的统计。
在启用 App 状态监控模块的时候,可以通过一下方式获取 App 状态的统计数据:
1 | BatteryCanary.getMonitorFeature(AppStatMonitorFeature.class, feat -> { |
在启用 Device 状态监控模块的时候,可以通过以下方式获取设备状态的统计数据:
1 | BatteryCanary.getMonitorFeature(DeviceStatMonitorFeature.class, feat -> { |
AppStat 和 DevStat 是耗电异常判断的重要依据,例如:在 App 开启前台服务或者浮窗的情况下,我们运行 App 出现比较高的功耗,同样在设备出于充电状态的情况下我们也可以适当放宽一些待机功耗的限制;相反在 App 处于后台并且设备处于息屏状态的时候,我们应当最大程度收紧待机功耗的阈值,取消 App 的后台任务或者降低相关线程的优先级,让 App 进入 Standby 状态并且让设备能成功进入 Doze 模式。
根据我们对多份耗电异常的 Battery Historian 的排查,我们发现设备无法进入 Doze 模式是系统耗电排行中电量曲线出现明显下降的主要原因。而导致设备无法进入 Doze 模式的主要原因基本都是 App 的进程优先级一直没有下降(比如有活跃的线程一直在 Running 导致 CPU Load 负载很高)。
电量相关系统 & App 的事件的监听是电量优化需要用到的基础功能,BatteryCanary 内部实现主要通过 BatteryEventDelegate 这个类来实现相关事件的监听,并且向外提供相应的接口以便上层业务在需要在时候做出功耗优化的策略调整。
1 | BatteryEventDelegate.getInstance().addListener(new ExListener() { |
正如上篇文章提及的一样,如何检测各种类型的耗电异常,以及如何提炼耗电问题的规则(划红线)是优化电量指标的关键所在
。从电量专项指标立项以来,我们项目在与 Android App 电量异常这项“疑难杂症”日常斗智斗勇的过程中,根据我们项目自身需要、Android Vitals 最佳实践指导 & 国内厂商自定义实现的系统电量异常告警功能的规则细节等,提炼出一套比较稳定的电量优化规则:
1 | # 待机功耗 |
上面着重强调的“线程待机功耗异常”以及“系统服务调用异常”则是电量指标中比较好制定的异常规则,其他规则的提炼(比如 App 整体前台功耗,或者使用使用某一业务功能时的功耗),则需要根据 App 自身实际情况,通过以上监控模块统计到的数据做进一步的拿捏。
BatteryCanary 把一些电量规则提炼中比较重要的数据做了易用性的封装,可以通过以下方式获取。
CPU Load 是判断 App 当前功耗大小的一个重要依据,通过 adb top
命令我们可以轻松地获取 App 进程 & 线程的 CPU Load 数据,而在 App 内获取这个数据却不容易,通常我们只有在 ANR 的时候才会看到 App 把这个数据 Dump 出来。
根据类似的计算方式,BatteryCanary 提供了相应的 CPU Load 获取接口:
1 | int cpuLoad = compositor.getCpuLoad(); // [0, 100 * cpu_core_num] |
CpuFreq 也是跟 CPU 功耗相关的一个主要的依据。一个 CPU 往往由几个集群(Cluster)组成,每个集群里面的 CPU Core 的规格都是一样的,比如我的手机就是由大(2 core)、中(4 core)、小(2 core)三个集群共 8 CPU Core 组成。CPU 工作时,每个 Core 会运行在不同的 CpuFreq,工作频率(Step)越高则功耗越大,相应的设备发烫也就更严重。
获取 App 当前瞬时的 CpuFreq 意义不大,我们需要通过监控 CpuFreq 的变化,来大抵推断出 App 在某个使用场景下 CPU 功耗的变化情况,如果 CpuFreq 变化太大且长时间处于比较高的 Step,则我们应该着重关注该场景下用户发热发烫的反馈情况,必要时候做出相应的降频策略。
Battery 提供以下的方式来采样 CpuFreq 的变化数据:
1 | Result sampling = compositor.getSamplingResult(CpuFreqSnapshot.class); |
我们认为设备机身温度也是电量优化的一个重要指标,遗憾的是 Android Framework 并没有提供相应的 API 来获取该数据(需要系统 App 权限)。好在电池温度变化与设备温度正相关性比较强,而且当设备整体功耗比较大的时候,电池的输出功率也比较大,从而电池温度也会上升得比较快,在一定程度上我们可以用电池温度作为依据来判断设备机身的发烫状况(不过快充等充电场景也会导致电池升温,需要做好相关的状态判断和过滤)。
类似 CpuFreq,当前电池温度也应该通过采样的方式来判断其变化状态:
1 | Result sampling = compositor.getSamplingResult(BatteryTmpSnapshot.class); |
为了方便统一管理数据,BatteryCanary 把电量问题相关的 App 状态、Device 状态、Activity 切换信息等数据都封装到 AppStats 这个类里,可以通过以下方式来获取当前时间窗口内的 AppStats:
1 | long duringMillis = xxx; // 时间窗口 |
除了以上介绍的各种异常的检测之外,如何将各种监控模块统计到的异常数据以一种“read-friendly”的方式展示出来也是个麻烦的事情。BatteryCanary 通过两种方式来展示统计数据:logcat 日志 & 时间轴 UI。
待机功耗监控的 CompositeMonitors(SCOPE_CANARY)会通过默认的 Dumper & Printer 来往 logcat 输出以下统计报告(其他监控场景 App 可以根据需要自定义自己的 Dumper),这里以开启全部监控功能的待机场景为例:
1 | Printer printer = new Printer(); |
Logcat 会输出以下格式化的文本:
1 | **__************************************** PowerTest ***************************************** |
启用 BatteryStatsFeature 功能的时候,BatteryCanary 会通过 MMKV 持久化电量统计数据,并通过 BatteryStatsActivity 以时间轴的形式展示相关的电量事件 & 电量报告,以便于快速排查并定位异常现场。
电量事件是判断 App 有没有出现电量问题的重要数据,主要包括以下事件:
1 | App 前后切换 |
电量报告主要包括是以上提及的各种监控场景的 CompositeMonitors 统计到的电量数据,以一种比较直观的方式展示数据,用于快速定位某个 App 使用场景下的电量问题。
参考链接:
]]>虫笼的卡伽斯特尔(虫籠のカガステル)是由桥本花鸟 创作的一部漫画,由 NETFLIX 进行动画化并于 2020 年先后在 NETFLIX 和 AT-X 放送。故事讲的是一个末日 & 废土设定的世界观。
一種怪病將感染者變成肉食性昆蟲。三十年過後,一名年輕的驅蟲人與一位青少女結伴尋找她的母親。
虽然国人人均白毛萝莉控,但是本番的女主却不是白毛。不知道为何,刚看到这部番的视频片段的时候,我就莫名地对女主有一种非常怀念的感觉。说实话,第一集并没有特别吸引我的地方,但是就是凭着这股怀念的感觉,我愣是一晚上看完了全部 12 集动画,顺便也补完了 7 卷的漫画。
看完后续在我补全 cv 信息的时候,我总算明白我的怀念感来自哪了。这部番的女主声优是花泽香菜,而 2007 年我正是通过《虫之歌》的女主 cv 认识并开始关注香菜,同样的末日 + 昆虫设定的世界观,同样的绿毛萝莉女主,同样的声优,这一切难道只是巧合吗…… 话说,是时候 pick 一下虫之歌,重新回温一番了。
第 1 集。牧羊女小波波
驅蟲人鬼道不僅替一名商人擔任警衛,還會在昆蟲襲擊時出手。隨後,他答應護送年輕的伊莉到母親身邊。
第 2 集。鯷魚與黑蜥蜴
殘忍的謀殺案曝光,鬼道懷疑不是昆蟲下的手。伊莉上市場,包包卻被偷了。
第 3 集。來自深淵
伊莉和赤鼠團的領袖結為好友。鬼道在瑪莉歐花園收到一封信,以及一張標示兇案地點的地圖。
第 4 集。無情的劍
軍隊準備殲滅潛伏在 E05 沙地下方的昆蟲。卡西姆依然對自己在五年前的作為耿耿於懷,於是自願當誘餌。
第 5 集。來自極東
伊莉從瑪莉歐口中得知鬼道的養父拉薩路用什麼狠酷的方法,讓他在卡伽斯特爾橫行的極東學得生存技能。
第 6 集。覺醒
一名自稱是伊莉父親的男子現身帶走伊莉。她與鬼道試圖逃跑,卻遭阿赫特伏擊,最終沒能脫逃。
第 7 集。伊利亞斯特爾
陣和鬼道尋找關於伊莉過去的線索,繼而發現格里菲斯為法蘭茲記錄的訊息,裡面不僅解釋自己的行動,也說明伊莉的出身。
第 8 集。蟲籠中的公主
阿德汗的人馬圖謀推翻商區長,並將 E05 變成蟲籠。鬼道逃到 E07,而伊莉正被關在這裡。
第 9 集。昆蟲饗宴
鬼道潛入 E07 的主塔尋找伊莉。伊莉得知自己出身的真相之後,宣佈自己是這處牢籠的下一任女王。
第 10 集。向上
伊莉與鬼道重逢後,一路朝王座所在之處挺進,沿途卻得對付阿德汗的維安部署,還有數不清的卡伽斯特爾。
第 11 集。朝向你……
身受重傷的法蘭茲引領伊莉來到女王的廳室,也將塔妮亞的命運交到她手上。阿赫特與鬼道展開終極對決。
第 12 集。最後,為了你
沙利夫的人馬和 E05 的保衛者之戰即將告終之際,E07 主塔裡的倖存者也得正視自己的未來。
原作漫画故事内容与动画相比不能说毫无差别,只能说 MD5 校验一致。不过漫画有不少补充的 SS 章节(Special Story),人设风格于 3 渲 2 的动画相比也有一股日本经典长篇漫画特有的风味,所以还是强烈推荐补一下漫画。
时隔几年,终于想起给自己的 Blog 除草了。所以呢…… 又又又到了折腾博客主题的时间了!毕竟,不纠结主题的 Bloger 不是好程序员,正经 Bloger 谁写博客啊。
很早之前就想给自己设计一个简洁点的 Hexo 主题,期间物色到了一位旅居日本的设计师的博客:Josui Writings。原博客的主题排版比较符合我的意向,不过细节上我还是给自己做了一些调整。
相关变更:
主题 Repo:hexo-theme-hacker。
最近我已经把自己的笔记系统迁移到 Notion 上面,相比过去十年间用过的为知笔记和有道云笔记,Notion 的自定义和自动化玩法显然要丰富许多,而且多个平台的客户端也做得不错(国产软件现在都是满屏的广告非常难受)。不过我总感觉 Notion 在数据同步和历史数据版本控制上做得比较搓。
比如我感觉我好像同步丢过几次数据,而且就算开了 Notion Pro 服务,Notion Page 的历史记录居然没有 diff
比对(这样很难看出不同历史记录之间的变更)。
因此我打算给 Notion Workspace 加上自动备份功能,用于防止数据丢失以及通过 git diff
查看不同备份快照之间的差异。
notion-up 是一个用来备份 Notion 数据的 Python Repo,主要功能是通过 Notion API 导出 Notion 的备份数据 exported.zip,配合 CircleCi 或其他持续集成服务,能够定期把 exported.zip 文件上传到 GitHub Releases 并把解压出来的 Notion 笔记内容提交到指定的 GitHub Repo。
通过以下命令可以备份指定 Notion Workspace 的数据并导出为 .zip 文件。(其中的 token_v2 的获取方式可以参考一下这个文档:notion_token_v2。)
1 | PYTHONPATH=./ python main.py --token_v2 <token_v2> |
搭配 CircleCi 等持续集成服务,可以实现定期自动备份 Notion 数据到 GitHub Releases 以及 GitHub 仓库。(详细配置可以参考一下:.config.yml。)
1 | workflows: |
效果如下:
Notion 数据备份文归档
Notion 数据 git diff
]]>割裂感
问题,我已经把博客日志的全部源文件迁移到自己的 Notion 笔记,同时通过自动化技术,使用 NotionDown 把 Notion Pages 解析成 MarkDown 源文件,并编译和部署成 Hexo 静态博客。相同的解决方案可以参考:https://github.com/kaedea/notion-down-hexo-showcase。
]]>当我们说一个 App 耗电的时候我们在说什么?
我们可能是指 App 吃 CPU 导致系统掉电快,也可能是在说系统告警 App 后台扫描频繁消耗电量,还可能是在说使用 App 时手机发烫严重…… 是的,相对于 Crash、ANR 等常见的 APM 指标,Android App 电量优化更像是一个综合性的问题。
一方面,造成 App 耗电的原因是多种多样的,比如 CPU/GPU Load、屏幕、传感器以及其他硬件开销等,每个分类的排查思路是大相径庭的,再加上 AOSP 没有“官方”的耗电异常检测框架,各个 OEM 厂商自家系统对 App 耗电的监控方案又各不相同(且没有充分的公开文档),所以检测方案需要结合具体 App 项目实际和用户反馈状况,针对具体的耗电类型做出考量和取舍。另一方面,耗电问题也经常是比较“主观”的,比如用户感觉 App 新版本掉电比较快了,或者在户外气温比较高的环境使用 App 时感觉设备发烫了,又或只是单纯的因为使用时间变长了导致系统耗电排行靠前了等等,这些通常都是一些比较微妙的主观感受,难以量化问题。
因此,如何检测各种类型的耗电异常,以及如何提炼耗电问题的规则(划红线)是优化电量指标的关键所在。微信 Android 项目在与 App 耗电异常这项“疑难杂症”日常斗智斗勇的过程中,产出了一些比较实用的工具和优化思路。本文针对 Anroid App 的耗电问题,具体分为“App 电量统计原理”、“耗电异常监控方案”、以及相关的“优化案例”三部分进行解析和分享。
了解 App 电量统计原理之前,有必要先复习一下电量计算公式:
电量 = 功率 × 时间
其中需要注意一点的是, 功率 = 电压 × 电流。而在数码产品中,元器件一般对电流比较敏感,而电压基本是恒定的,所以我们直接使用电流来代替功率,这也是我们经常说“毫安时”(mAh)而不说“千瓦时/度”(kWh)的原因。
了解计算公式之后,App 的电量统计思路就比较清晰了:
App 电量 = SUM( 模块功率 × 模块时间 )
其中模块主要是指 Android 设备的各种硬件模块,主要可以分为以下三类。
第一类,像 Camera/FlashLight/MediaPlayer/一般传感器等之类的模块,其工作功率基本和额定功率保持一致,所以模块电量的计算只需要统计模块的使用时长再乘以额定功率即可。
第二类,像 Wifi/Mobile/BlueTooth 这类数据模块,其工作功率可以分为几个档位。比如,当手机的 Wifi 信号比较弱的时候,Wifi 模块就必须工作在比较高的功率档位以维持数据链路。所以这类模块的电量计算有点类似于我们日常的电费计算,需要“阶梯计费”。
第三类,也是最复杂的模块,CPU 模块除了每一个 CPU Core 需要像数据模块那样阶梯计算电量之外,CPU 的每一个集群(Cluster,一般一个集群包含一个或多个规格相同的 Core)也有额外的耗电,此外整个 CPU 处理器芯片也有功耗。简单计算的话,CPU 电量 = SUM( 各核心功耗 ) + 各集群(Cluster)功耗 + 芯片功耗 。如果往复杂方向考虑的话,CPU 功耗还要考虑超频以及逻辑运行的信息熵损耗等电量损耗(这方面有兴趣的话可以自行拓展查证,Android 系统 CPU 的电量统计只计算到芯片功耗这一层)。屏幕模块的电量计算就更麻烦了,很难把屏幕功耗合理地分配给各个 App, 因此 Android 系统只是简单地计算 App 屏幕锁(WakeLock)的持有时长,按固定系数增加 App CPU 的统计时长,粗略地把屏幕功耗算进 CPU 里面。
最后,需要特别注意的是,以上提到的各种功率和时间在 Android 系统上的统计都是估算的,可想而知最终计算出来的电量数值可能与实际值相差巨大,Facebook 的工程师对此也有所吐槽:Mistrusting OS Level Battery Levels,这点大家心里要有一点概念。
Android 系统的电量统计工作,是由一个叫 BatteryStatsService 的系统服务完成的。
先了解一下其中四个比较关键的角色:
BatteryStatsService 的工作流程大致可以分为两个部分:时长统计 & 功耗计算。
BatteryStatsService 框架的核心是 ta 持有的一个叫 BatteryStats 的类,BatteryStats 又持有一个 Uid[] 数组,每一个 Uid 实例实际上对应一个 App,当我们安装或者卸载 App 的时候,BatteryStats 就会更新相应的 Uid 元素以保持最新的映射关系。同时 BatteryStats 持有一系列的 StopWatch 和 SamplingCounter,当 App 开始使用某些硬件模块的功能时,BatteryStats 就会调用相应 Uid 的 StopWatch 或 SamplingCounter 来统计其硬件使用时长。
这里以 Wifi 模块来举例:当 App 通过 WifiManager 系统服务调用 Wifi 模块开始扫描的时候,实际上会通过 WifiManager#startScan() --> WifiScanningServiceImp --> BatteryStatsService#noteWifiScanStartedFromSource() --> BatteryStats#noteWifiScanStartedLocked(uid)
等一连串的调用,通知 BatteryStats 开启 App 相应 Uid 的 Wifi 模块的 StopWatch 开始计时。当 App 通过 WifiManager 停止 Wifi 扫描的时候又会通过类似的流程调用BatteryStats#noteWifiScanStoppedLocked(uid)
结束 StopWatch 的计时,这样一来就通过 StopWatch 完成 App 对 Wifi 模块使用时长的统计。
具体电量计算方面,BatteryStats 是通过 ta 依赖的一个 BatteryStatsHelper 的辅助类来完成的。BatteryStatsHelper 通过组合使用 Uid 里的时长数据、PoweProfile 里的功率数据(power_profile.xml 的解析实例)以及具体各个模块的 PowerCalculator 算法,计算出每一个 App 的综合电量消耗,并把计算结果保存在 BatterySipper[] 数组里(按计算值从大到小排序)。
还是以 Wifi 模块来举例:当需要计算 App 电量消耗的时候,BatteryStats 会通过调用 BtteryStatsHelper#refreshStats() --> #processAppUsage()
来刷新 BatterySipper[] 数组以计算最新的 App 电量消耗数据。而其中 Wifi 模块单独的电量统计就是在 processAppUsage 方法中通过 WifiPowerCalculator 来完成的:Wifi 模块电量 = PowerProfile 预置的 Idle 功率 × Uid 统计的 Wifi Idle 时间 + 上行功率 × 上行时间 + 下行功率 × 下行时间。
1 | public class WifiPowerCalculator extends PowerCalculator { |
作为补充,这里罗列几个 BatteryStatsService 系统服务的应用场景来说明其工作方式。
通过以上分析,我们其实已经知道 Android 系统 App 耗电排行是通过读取 BatteryStatsHelper 里的 BatterySipper[] 数据来实现排行的。一般情况下,BatteryStats 的统计口径是 STATS_SINCE_CHARGED
, 也就距离上次设备充满电到现在的状态。不过个别 OEM 系统上这里的统计细节有所不同,有的 Android 设备系统可以显示最近数天甚至一周以上的 App 的电量统计数据,具体实现细节不得而知,姑且推断是根据 BatteryStatsHelper 自行定制的服务。
或许你已经知道怎么通过 adb dumpsys batterystats
或者 adb bugreport
Dump 出系统的电量统计数据,以及如何配合 Battery Historian 工具来分析这些数据,实际上这些 adb 命令都是通过 BatteryStatsService 查询 BatteryStats 里持有的 Uid[] 来获得相应的电量统计数据,具体实现可以参考 com.android.server.am.BatteryStatsService#dump
。
“CPU Load xx% yy% zz% ” 之类的数据相信大家都或多或少见过,ANR 的 traces.txt、以上的 batterystats 和 bugreport Dump 出来的数据,以及 adb top 命令里都会显示类似的 CPU 负载数据,实际上这个数据也是通过 CPU 模块的统计时长来计算:CPU Load = SUM(App CPU Core 时长时间) / CPU 工作时间。需要注意的是 App CPU 时长是按 CPU Core 为单位分开计算的,所以计算结果完全可能超过 100%,比如一个 8 核心的 CPU 计算结果的理论上限是 800%。
经过以上分析,我们知道 BatteryStatsService 里已经有比较详细的 App 电量统计数据。不过上帝刚给我们开了一扇窗,转身就把门给拆了。实际上这个系统服务对 App 是隐藏的,也就是说在 App 里我们无法直接访问 BatteryStatsService 里的数据(HealthStats 服务能间接访问一部分数据),不过这也不是说我们完全没有办法。
既然我们已经知道了 Android 系统的调用统计原理,那么用类似的计算方案在 App 内部进行电量统计应该也能得到一个近似解。这也是 Matrix BatteryCanary 的核心原理,具体实现大致可以分为两部分:线程监控 & 系统服务调用监控。
实际上,我们除了通过 SystemClock.currentThreadTimeMillis()
来获取当前 Java 线程的工作时间此外,并没有直接的办法能够直接获取 App 所有线程的工作时长和状态,幸运的是 Linux 的 proc 命令可以给我们提供一些帮助。
Linux 命令 proc/[pid]/stat
和 proc/[pid]/task/[tid]/stat
可以 Dump 当前 App 进程和线程的统计信息。
1 | > cat /proc/<mypid>/task/<tid>/stat |
这里比较关键的数据是 进程/线程名
、进程/线程状态
,以及第 13 - 16 位的 utime
、stime
、cutime
和 cstime
。utime 和 stime 分别是进程/线程的用户时间和系统时间,而 cutime/cstime 是当前进程等在子进程的时间(在 Android 进程上大都是 0)。实际上我们对这些数据内容也不完全是陌生的,Logcat 里一些线程相关的 syslog 也有类似的输出,如下图所示。
在这里有一点需要单独拎出来讲:utime 和 stime 具体代表什么意义呢?我们已经知道它们是表示线程的工作时长,但实际上其单位 jiffy 并不是一个时间的单位,而是一个频率的单位!
就如你所看到的,上图截图中有个圈起来的线程数据:HZ=100。这个数值代表当前 Linux 系统给进程(具体点说是线程)分配 CPU Slice 资源的周期频率是 100Hz,换句话说就是系统软件中断的频率是每秒 100 次。假如一个周期的这 100 个 CPU Slice 全部分配给某个线程,那么这个线程的 CPU 开销就是 100 Jiffies,共占用 CPU 一个周期的时间 (1 秒)。
Android Linux 上,100 Jiffies ≈ 1 Second
所以我们可以记住一个比较重要的结论:在 Android 系统上,Jiffy 和 Millis 的换算关系大概是 1 比 10。(100 Hz 是一个 Linux 系统的编译参数,在不同的 Linux 版本上这个值可能是不同的。)
系统服务调用监控就比较简单了,主要分为 SystemService Hook 和 ASM 插桩两种方案,具体实现大家应该都比较熟悉了,这里不再赘述。不同需要注意的是,SystemService Hook 在不同 Android API Level 上面存在较多的兼容性问题(特别是在新 API Level 的变动上面坑比较多),而 SystemService ASM 经常会插漏一些运行时动态加载的插件。具体生产环境的应用上,以 ASM 为主 Hook 为辅的方案比较合适。
以上提到的监控方案,已经能兜住大部分的 App 耗电监控需要,不过在实际具体项目的实现细节上,依然存在不少的挑战。
电量监控跟其他质量指标监控相比有个不同的地方就是 ta 是一个过程,其他异常比如 Crash / ANR 发生的时候是瞬时的。比如 Crash 发生的时候,可以一并获取 App 的前后台状态再上报,这样我们就能有效区分前台、后台 Crash(这很重要,因为前台 Crash 优先级要高许多)。然而电量上报就不能这么做了,因为我们监控到一个 App 在 10 分钟的统计窗口里出现了耗电异常,当我们上报的时候获取得到 App 状态是后台的,然而 App 完全有可能前面 9 分钟都是前台的,最后 1 分钟才进入了后台,因此这样我们统计出来的状态数据是有失真的。同样,设备充电、亮灭屏等状态也有类似的问题。
这个问题早期给我们带来不少麻烦,甚至造成一些电量 Bug 交付的乌龙。好在我们很快就发现这个问题并做出了改善:类似 CPU Slice,我们使用一种叫做 Event Slice 的方案来计算一段时间窗口内 App 的状态占比。根据上图,在设备状态发生变化的时候,我们记下每一个 Event 的 Stamp,统计 App 状态时,根据统计窗口的起始时间和结束时间,我们就可以计算出这段时长内 App 每个事件状态的占比。
通过 procStat 监控到的异常线程,如果其业务相关性比较强的话一般都能很快解决。不过如果出现异常的是线程池之类的线程就比较麻烦了:和上面的状态统计一样,线程出现异常的时候,正在执行的 Runnable 任务,不一定就是真正导致异常的元凶。
为了解决这个问题,我们设计了一下两种方案:
Wrapper#run
里执行 TaskRunnable 的前后,分别计算当前线程的 Jiffies 差值 Delta,已得到当前 TaskRunnable 的实际 Jiffies 开销。通过线程 Jiffies 和线程 State,我们可以监控到某些线程出现了异常,比如 App 待机的时候线程一直处于 Running 状态(State R)并且持续消耗 Jiffies。毋庸置疑,我们已经发现了 App 耗电异常的原因和问题现场。这时候新的挑战出现了,虽然我们已经足够确信目标线程出现耗电异常,但是这类有问题的线程并不是一直有 Log 输出的(特别是处于死循环状态的线程),缺乏现场日志,我们分析和优化耗电异常的效率将大大降低。
虽然我们很快想到了用 Thread 相关的 API 来 Dump Java 线程异常现场的 StackTrace,但是对于 Native 线程却是一丁点儿办法都没有。无奈之下,一开始我们也只能用一些比较笨的办法来处理,比如 Review 可疑代码并加多 Log,甚至通过 tkill/tgkill 的方式来主动触发 Native 线程 Crash 从而把 StackTrace Dump 出来。后来我们发现一种成本比较低的方案:通过主动模拟 ANR 触发系统 Dump 线程 Trace Log 从而获取所有线程的 StackTrace(目前由 Matrix AnrDumper 工具实现)。
以上陈述的各种方案,已经帮我们项目监控到了大部分的耗电问题,以下列举一些优化案例作为分享。
很多看似复杂的耗电异常问题(特别是高耗),最终排查下来都是一些看似简单的“低级错误”导致,如果一个个单独拎出来讲解的话,总有一股“走近科学”的味道,所以这里聚合成几类问题分别进行讲解。
相信不少人同我一样有一个朴素的开发观念,那就是对于一个长时任务就应该把 ta 丢进 Worker 线程、线程池里去执行。换句话说,只要不在 UI 线程执行,大家对 ta 性能开销的敏感度或者心理预期就大大降低了。然而,这类长时任务,或者频繁的短时任务有时候也会造成 App 后台 CPU 负载异常。
比如微信部分为了优化用户体验的 PreLoad 逻辑(Emoji 资源、朋友圈资源等),会直接被丢进线程池里执行,有时候 App 已经待机好长一段时间了,相关的预载逻辑还在执行,从而导致系统 CPU Load 异常告警。又比如,有个古老的轻量 IO 逻辑为了方便是直接在 UI 线程里执行的,后来我们发现这里产生 ANR 了,然后就把这个逻辑挪到了 Woker 线程里执行并解决了 ANR,然而这个逻辑实际上在极端的条件下是因为出现死循环才导致的 ANR,这样简单的处理方案会导致 Woker 线程进入死循环并且不宜被发现。
类似的“低级错误”还有好多,这给我们提供一个深刻的教训:运行时间在分钟量级以上的任务都应该考虑待机耗电问题,而不是简单地异步化就完事了。在这方面,BatteryCanary 提供了电量相关的生命周期接口和事件回调,用于在 App 进入待机后的某个恰当时机来退出长时、频繁任务。
Android App 耗电异常发发生后台的状态的案例比较多,实际上 Android 系统的性能优化也一直朝着收敛 App 后台活动范围的方向发展,所以我们有必要在 App 进入后台并持续一段适合的时间后,执行一些退出和析构逻辑,并严格限制后台 Task。不幸的是,我们也在这里翻车了。
早期微信小程序框架并没有提供持续定位的接口,部分需要持续定位的小程序则是通过循环调用单次定位接口来实现目的,而有时候定位服务需要通过 Wifi 或者 Bluetooth 来实现辅助定位,所以一旦小程序进入后台后还在循环调用单次定位接口来实现实时导航等服务时,我们不仅获取了一堆系统的后台 GPS/Wifi/Bluetoosh 扫描异常告警,还成功把 App 推上系统耗电排行榜的宝座。
另一次翻车则与播放器有关:微信的视频播放有个自动播放的逻辑,当视频 Prepared 完毕就可以自动 Loop 播放,当 App 进入后台时候停止视频播放,然而这里有个遗留的代码缺陷:
1 | fun onBackground() { |
也许你已经看出问题了,当 App 进入后台时,如果 Player 还没有完成 Prepare,那么 ta 将彻底失去 stop 的机会,当缓冲完毕的时候,播放器就会一直在后台 Loop 播放了。
此外,Android App 前后台状态的判断有很多种办法,这里必须指出的是部分方案是有版本兼容性问题的,比如有个方案是通过判断 App 自身是否在 ActivityManager#getRunningAppTasks
数组顶部来判断 App 是否在前台,然而 Android L 之后这个 API 只会返回 App 自身相关的 Tasks,也就是说结果会恒为前台,最终导致有些需要通过这个 API 来判断并限制后台活动的任务就会失控。(作为目前比较靠谱的方案,可以考虑通过 ActivityManager.RunningAppProcessInfo#importance
的值来判断 App 前后台状态。)
Loop 循环控制异常是耗电问题的重灾区。
有些算法实现使用 while(true) {}
之类的 Loop 结构来完成通常会比较简单和直观,然而这里也是比较容易翻车的。
1 | // case 1: 退出判断不在当前 while block 内部 |
对于 Case 1,因为作为 Exit Conditioning 的判断方法 shouldExit()
不在 while 循环体内部,很容易在后续的代码迭代过程中因为调整了 shouldExit 的实现细节,导致 while block 出现了死循环的破绽。而对于 Case 2,这实际上是微信播放器某个老版本的实现,通过“状态机 + 责任链”的模式能有效地解耦播放器各个业务模块的控制逻辑,但是这样的设计实际上也是把 while block 的 Exit Conditioning 挪到了外部的 Consumer 里,容易因为出现某个新添加进来 playerStatus 所有的 consumers 都无法消耗,从而出现 while 死循环。
目前 Android 项目中,Java 8 或者 Kotlin 相对 Java 7 的占比还是比较小(特别是规模比较大的项目),因此在处理一些比较复杂的数据结构时,相比起使用新语言特性中 Collection 相关的“流式编程”,我们更加偏向使用传统 Loop 嵌套的方式来实现数据结构的转化,而且其中往往还伴随着比较严重的“胶水代码”,这也给 Loop 循环控制埋下了隐患。
1 | // Loop 套娃 |
例如我们项目中,就发现不止一处在类似以上代码的 Loop 嵌套结构中,因为“忘记”自己当前在哪一层,使用了错误的中断或者退出语句,结果导致了外层或者内层 Loop 死循环的悲剧。
我们经常需要周期性地执行某些逻辑,所以我们也写了不少以下结构的代码来实现 Polling 操作:
1 | // Loop + Sleep = Polling |
实际上这样的写法也有不小的隐患:一旦 try-catch 代码块出现了异常,则 Loop 逻辑可能再也没有办法进入预设的 Sleep 状态,结果是还是死循环。令人意想不到的是,除了 Java 外,Native 代码也有类似的 Polling 问题。
1 | while (true) { |
我们发现个别机型会因为某些具体未知的原因(比如 App 进入后台因为系统限制 fd 出现了异常)导致 poll 系统调用失败,异常的 poll 调用会立即返回,而不是按照预想的每次 block 住线程 1000ms,最终也导致了 Native 线程出现了死循环。
以上 Loop 问题产生的原因虽然各不相同,但是结果却“出奇”地殊途同归:线程死循环。这是导致 App 出现系统耗电排行“屠榜”的重要原因,我综合地将以上所有的 Loop 问题原因归纳为“Loop 退出逻辑不完备,或者不健壮”。在编写类似 Loop 结构体的时候必须格外谨慎,最好是在 App 进入待机状态后有一个全局退出逻辑来作为兜底冗余。
或许大家对于 HashMap/HashSet 等 Collection 类的线程安全问题并不陌生,但是我是没想到 HashMap#put()
和 HashSet#add()
等操作也会造成线程死循环。
在一次排查线程耗电异常的 case 中,我们发现目标线程的 Stacks 如下:
1 | | -> (RUNNABLE)ThreadPool#Thread-1(761) |
一开始我怀疑 java.util.HashMap.put(HashMap.java:425)
这里是在内部自旋等待其他线程的操作所以没有仔细深入分析问题。随着类似的 Case 越来越多,我很快发现相关线程只要进入 HashMap#put()
后就再也没有退出,这基本可以排除自旋的可能性了。深入排查后,我们发现原来非 Concurrent 的 Collection 类的线程安全问题,除了造成“数据丢失或者读取脏数据”之外,还可能造成“线程死循环”。
问题的原因是当 HashMap 需要扩容的时候,需要进行一次 resize/rehash 的内部操作,在 Java7 上面这个操作需要进行链表重置。当重置链表过程出现并发操作时,就容易导致链表的元素出现循环的“死链”(多个线程同时迭代集合的元素),最终导致 HashMap#put()
调用进入死循环。
具体死链的原因分析请参考网络上其他详细的分析文章(Java 8 中相关链表设计被替换成红黑树,所以可以避免死链的问题,但是 HashMap 的线程安全问题仍然需要重视。)。
当我们发现 App 耗电的时候能做些什么?
现在,除了像以往一样通过 adb top 查看 App 进程/线程异常,或者通过 adb dumpsys batterystats 和 Battery Historian 查看 App 耗电数据之外,我们还能通过 BatteryCanary 实现 App 耗电问题的检测和线上监控。
文章最后,简单推广一下 BatteryCanary 的使用方式,主要包括以下两部分。
BatteryCanary 默认会将一系列与电量相关的生命周期和事件输出到 Log 里,通过过滤 TAG Matrix.battery.LifeCycle
就可以获得相应的日志。
除了 Battery 生命周期事件之外,BatteryCanary 还会周期性的 Dump 当前 App 的电量统计报告。当出现线程状态以及 Jiffies 开销异常的时候,还会将线程的 StackTrace Dump 出来。
以上方某用户反馈的耗电问题为例,可以按照以下流程来排查耗电问题:
根据以上的排查结果,可以发现导致 App 耗电的主要原因是“Thread-29”线程在后台期间一直 Running,在电量统计报告的下方也可以看到该线程的 StackTrace Dump,从中可以定位到该线程一直在 mg_mgr_poll 方法中循环…… 至此,后续的工作就简单多了。
目前,BatteryCanary 作为 Matrix 项目集成的一个插件模块,已经在微信 Android 项目上稳定运行多个版本,并且帮助我们成功定位和优化多个严重的耗电问题,欢迎各位前来食用和反馈:https://github.com/Tencent/matrix。
以下内容为拓展阅读,罗列的数据仅供参考,具体以大家实际测试和使用感受为准。
电量监控本身势必给 App 带来额外的功耗,所以全量应用在线上环境的时候要格外小心。BatteryCanary 框架的监控功耗主要是在系统服务调用(统计 App、Device 状态需要)和 procStat 数据解析这两个方面。
Pixel 1/Pixel 4/Pixel 5 设备上的测试数据如下:
1 | ## Linux procStat 数据解析 |
目前 Android 平台电量相关的开源方案并不多,我们只发现 Facebook 有个电量统计相关的开源项目 Battery-Metrics 能满足类似的需求,其基本设计思路与 BatteryCanary 类似,都是模拟 BatteryStatsService 的统计行为来测量 App 的电量消耗。不过 Battery-Metrics 的设计初衷是其工程师不“信任”Android 系统的耗电报告,因此编写此框架来统计他们自己想要的电量指标,两者在具体实现和数据取舍上的区别比较大。
[1] Matrix BatteryCanary
[2] power_profile.xml
[3] BatteryStatsHelper
[4] Battery-Metrics
个人认为,笔记(Note)、写作(Writing)和分享(Share)是 个人知识管理
重要的组成部分。笔记是知识元素,写作是知识汇总,分享是知识升华。固然每个人具体实践的方式会尽不相同,不过大家应该都或多或少能对体会其中存在的一些割裂感:
其一,笔记存在多端同步编辑的刚需。不过随着云笔记解决方案越来越成熟后,这问题现在已经有许多解决方案。其二,笔记草稿和写作正文之间的同步存在许多机械的地方:同一篇文章经常需要在草稿和正文(终稿)之间来回修订,而大部分情况下这两者的同步是通过复制粘贴和人工比对来完成的,这个过场是写作体验主要的割裂感之一。其三,写作正文完成之后的文章分享(Publish)也是一个麻烦的流程,尽管现在许多静态博客可以通过自动化技术完成部署,不过文章正文内容和部署用的 MarkDown 源文件之间的数据同步也是个非常头疼的事情:如果正文和 MD 文件分开处理,两者之间只能手动同步;如果直接用 MD 文件来写正文,又不得不面临现在多数云笔记糟糕的 MD 文件编辑体验(而且 MD 文件能否导出还是个未知数);如果干脆使用 gitbook 之类的方案来编辑 MD 文件,那基于 git 的笔记云同步方案体验也不会好到哪。
自从改用静态博客代替 WordPress 来发表自己的文章、文档后,我不得已采用”云笔记写草稿,MD 文件保存文章正文,手动在草稿和正文之间同步“这样的 原始
的写作方案,以上说的几种割裂感也是一直以来我感到非常困扰的地方。
几番苦寻更好的云笔记体验方案,未果。直到 ta 的出现:Notion
。
Notion 是一款时下比较流行的云笔记服务,虽然上线比较晚,不过 ta 却是众多云笔记里面”最靓的仔“。基于巧妙和独特的文本元素关系设计,Notion 可以将你的 Notes、Project Tasks / Plans、Doc / Wikis 等统统整合起来(还提供许多优秀的 Template 以满足不同的文档需要),在 ta 身上我看到了知识管理和项目管理的“大一统”的希望。
简单来说,Notion 抛弃了传统以段落(paragraph)为原子单位的做法,而是将所有的一切都当做 Block(是 Everything is Block)。一段文本是一个 TextBlock,一张图片也是一个 ImageBlock,Block 之间可以随意移动和嵌套。同时 Notion Block 被巧妙地设计成组合模式:一个 Block 可以是单一的 Block 也可以是 BlockGroup。这使得 Notion 可以满足几乎所有的文本关系,一片文章本身就是一个 PageBlock,PageBlock 即可以包含各种类型 Block 也可以包含 BlockGroup(用来存放 Table、Columns 等文本组),甚至文章本身就是一个 BlockGroup(类似于 Folder,用于存放一组子文章)。此外,Notion 的强大之处还在于 ta 提供了 CollectionBlock(可以认为是一个简化的 Excel),配合相关的 Notion APIs 我们甚至可以把其当做一个数据库来使用。
鉴于 Notion 优秀的多端同步服务和灵活的文本存储功能,我从开始接触到 ta 的时候就产生了“基于 Notion 优化一下自己的知识管理方案”的想法。不过眼下有两个问题还需进一步观察:其一, Notion 一开始是收费的(而且不便宜),我担心收益出现边际效应,成本不可控;其二,Notion 并没有 Official APIs,这会影响基于 Notion 的二次开发的稳定性,而稳定性又是自动化实践里非常重要的考量。
随着 Notion 开放免费的个人账号(Personal Plan)和官方 APIs 计划的展开,这些顾虑都再也不是问题。
如何优化自己现有的知识管理方案呢,我的基本设想是这样的:
如果有需要,作为兜底策略可以定期导出 Notion 全部笔记数据并做好保存和版本控制,从而弥补 Notion 付费才能使用的历史记录功能和为需要从 Notion 迁移数据这种状况做准备(尝试过云笔记数据迁移的朋友应该知道这是什么考量)。
工作流程示意图如下:
基于 CAP 编程原则,能复制的代码绝不自己写,一开始我抱着侥幸的心态去 GitHub 上面搜索,还真让我找到了类似的工程 notoma。不过项目还处于 WIP 状态,等了快一年作者还没有什么动作,Demo 也是处于无法运行的状态,所以我索性自己动手好了。
自己写一个 3rd-party 的 Notion APIs 不太现实,好在同样的 GitHub 上面已经有先驱做了类似的项目 notion-py,相比之下这个项目的完成度已经非常高了(目前唯一的遗憾就是尚未支持 Notion private 笔记的访问,且项目开发文档有限)。
基于 notion-py 我写了一个用来支撑自己知识管理的 Notion 笔记导出项目 notion-down,主要用来自动从云笔记导出 MD 文件和部署博客(配合 circleci + 静态博客)。
NotionDown 的主要功能如下:
如此一来,以后岂不是可以专心写作了?🤣(鬼咧,正经 Bloger 谁写文章啊,不都是在折腾博客主题吗。)
如果只是需要自动把 Notion 上面的笔记部署到静态博客,我这发现一个更简单的解决方案:Notion + GatsbyJs + Netlify 极致的博客体验。
其基本思路是通过 Netlify 作为 Trigger 触发 GatsbyJs 插件服务读取 Notion 笔记数据,并存放到 Gatsby 平台,最终通过 Gatsby 提供的博客服务展示博客内容。有兴趣可以了解一下 Gatsby,这套方案可以节省 circleci 和静态博客 generating 等不少中间流程。
]]>Notion Down とは、 Notion ページを Markdown ファイルにコンバートする Python ツールです。ちなみ、Hexo などのブログとのインテグレーションも可能です。このレポのインスピレーションやゴールは、「Notion のみでノートしながら自動的に目標な MD ファイルを生成する」ことで、ライティングの断片化を避ける。
kaedea.com
hexo.kaedea.com
基于 Notion 的笔记写作和博客分享自动化方案
今 NotionDown のできること:
ShortCode
blocks that control parametered MD files generatingNotionDown は notion-py を使って Notion Page のデータを読み込む、そしてコンフィグレーションによって、目標な MD ファイルを生成する。
notion-down >> Notion APIs (notion-py) >> Notion pages data >> generating MD files
WebHook >> notion-down >> Notion APIs (notion-py) >> Notion pages data >> generating MD files >> Copy into Hexo source >> generating webpages >> push to GitHub pages
準備:
notion_token_v2
を取る。public notion blog_url
を取る。notion-down/main.py
を実行する。notion_token_v2
のゲットする方法は ここに ある。
NotionDown Posts Template を自分の Notion Workspace にコピーする、そしてそのURLを blog_url
をしてつかいます(既存するルーツページを使うのもオッケー)。ちなみに、ルーツページを Public にセットすることが必要です。
notion-down/main.py
の使い方:
1 | # Run with cli cmd |
くわりコンフィグレーションはここに Custom Configurations。
以降は NotionDown のそれぞれな使い方を紹介する。
チェック /.circleci/config.yaml
:
test-build-readme
: README.md を生成する CircleCI Jobs。test-build-hexo
: デーモン Hexoソースコードを生成する CircleCI jobs、チェック https://github.com/kaedea/notion-down-hexo-showcase。test-run-pycorrector
: スペルチェックの CircleCI jobs。デーモンスクリプトは /jobs にあり、そしてアウトプットは /dist。
チェック test/
。
Notion Down 是一个用来把 Notion Page 转换成 Markdown 文件的 Python 工具,同时还提供了一些如 Hexo 静态博客构建的集成功能。其灵感和目标是通过“在 Notion 上写作并自动生成目标 MD 文件”来解决写作的割裂问题。比如:在 Notion 上编辑日志,并按照不同渠道配置生成目标 MD 文件;将指定 Notion 日志生成 Hexo Page 并自动部署到静态博客。
kaedea.com
hexo.kaedea.com
基于 Notion 的笔记写作和博客分享自动化方案
现在 NotionDown 提供以下功能:
ShortCode
blocks that control parametered MD files generatingNotionDown 通过 notion-py 读取 Notion Page 的数据,然后再(根据配置)解析成 MD 文件。
notion-down >> Notion APIs (notion-py) >> Notion pages data >> generating MD files
WebHook >> notion-down >> Notion APIs (notion-py) >> Notion pages data >> generating MD files >> Copy into Hexo source >> generating webpages >> push to GitHub pages
前置准备:
notion_token_v2
.public notion blog_url
as root post for NotionDown to get the pages you want to handle.notion-down/main.py
with your configs.参考 这里 获取 notion_token_v2
。
复制一份 NotionDown Posts Template 到你的 Notion Workspace 并把其 URL 做为 blog_url
(或者直接使用你自己已有的 Notion Posts)。需要注意的是,现阶段配合 notion_token_v2 使用的 Notion Posts 必须是 Public 的。
执行 Python 基本 notion-down/main.py
:
1 | # Run with cli cmd |
更详细的参数配置,请参考 Custom Configurations。
以下提供几种使用 NotionDown 的案例。
参看以下 CI 脚本 /.circleci/config.yaml
:
test-build-readme
: CircleCI jobs 用来生成 NotionDown 的 README.md。test-build-hexo
: CircleCI jobs 用来演示如何生成 Hexo 源文件 https://github.com/kaedea/notion-down-hexo-showcase。test-run-pycorrector
: CircleCI jobs 用来演示如何执行拼写检查。单元测试放在 test/
。
Bash,一门典型的面向 Google 编程的语言(类似的还有面向 StackOverflow 编程的 vim),最大的语言特性是其“不确定性原理”(你要么学会了 Bash 了语法,要么写出了能正常运行的 Bash 脚本,但是不能即学会了语法又写出了脚本),你今天刚学会的语法,到了明天就不一定会记得了;今天刚写好的代码,到了明天就不一定能运行了。与其回忆昨天学了什么,还不如直接问 Google。
Bash 学习的深入可真是“从入门到入土”,个人感觉最佳实践应该是把 Bash 作为脚本入口,包一层别的脚本语言(比如 Python),后续工作全交由后者处理。
一般来说,越是成熟的 Android 项目,Native 代码的贡献量就越多,以往 APK 体积的主要占比大都是资源文件,不过现在 Native 代码带来的 so 体积占比也很可观了,所以 so 动态化的价值越来越凸显。另一方面,现在支持 arm64 的 Android 项目也越来越多,Google Play 更是强制要求支持 arm64,所以有的 Android 项目需要内置两种甚至以上 abi 支持(比如 B 站客户端项目就同时支持 arm32/arm64/x86 三种,以往还支持 arm5),结果就是 so 体积成倍地上涨。因此,能不能将非主要的 abi 相关的 so 文件动态化,也成为了国内 Android 项目瘦身优化不得不优先考虑的问题。
此外,一些第三方 SDK 库也自带了不少 so 库(比如腾讯视频 SDK,以前我在接入这个 SDK 的时候,项目本身才 15 MB 体积,而 SDK 自身 so 已经占了 17 MB),或许是为了精简第三方 SDK 带来的体积,或许是为了隔离第三方 SDK 的 API(项目只自身依赖自己定义的业务相关性 API,通过依赖注入的方式访问第三方 SDK 的实现,这样以后更换 SDK 的时候只需要切换依赖注入的形式即可),都需要具体的 so 动态化方案提供技术支撑。
动态下发 so 库,看上只是把原本就算运行时动态加载的 so 文件,从 APK 安装包里面抽离出来,工作流程上变化不大,但实际上这也是一种完备的插件化技术,也就是说所有插件需要面临问题的问题我们统统需要考虑。我在以往的文章 使用 SO 库时要注意的一些问题 中简单谈过 so 动态化的一些问题,不过那些问题也仅仅是在 DEMO 项目里进行挖掘,当具体投入到生产项目中时,面临的挑战要严峻许多。以下我针对实际投产时遇到的问题进行一一分析讲解。
动态化本质上就是运行时加载可执行代码,而所有可执行代码在拷贝安装到安全路径(比如 Android 的 data/data 内部路径)之前,都有被劫持或者破坏的风险。so 动态化也不得不考虑这个安全性问题,最好的做法是每次加载 so 库之前都对其做一次安全性校验。考虑到检查带来的时间成本,可以假设内部路径是无条件可信的(对 Android 来说, data/data 路径在设备 root 情况下是不安全的;而且除了劫持风险外,内部路径文件有可能被应用自身一些不当文件操作给破坏导致插件不完整,因此如果要考虑绝对安全,内部路径插件被加载也必须做安全检查),在 so 文件拷贝到内部路径后单独做一次检查,检查失败就丢弃文件走 fail 逻辑,检查通过就生成一个 flag 文件作为标志,以后通过判断 flag 标志是否存在来决定是否需要执行安全检查。
怎么校验安全性呢?
最简单的方式是记录 so 文件的 MD5 或者 CRC 等 Hash 信息(粒度可以是每个单独的 so 文件,或者一批 so 文件的压缩包),将信息内置到 APK 内部或者服务器(如果保存在服务器,客户端需要通过类似 HTTPS 之类的可信通道获取这些数据),通过校验 so 文件 Hash 信息是否一致来确保安全性。不过 Hash 信息一般都会随之 so 文件的变动而改变,每次都需要调整这些数据比较麻烦,我想到的优化方案是“通过类似 APK 安装包签名校验的方式来确保安全性”:将 so 文件打包成 APK 格式的插件包并使用 Android Keystore 进行签名,将 Keystore 的指纹信息保存在宿主包内部,安全检验环节只需要校验插件包的签名信息是否和内置的指纹信息一致即可。(一种优化的方案是,使用和宿主包一样的 Keystore 给插件包签名,检验环节只需要检查插件和宿主的签名信息是否一致。)
具体代码实现可以参考一下:Installer#checkSafety()。
和一般的插件化方案一样,so 动态化也必须处理好版本控制问题:从 APK 里把 so 剥离出来后,我们除了要保证 so 文件的安全性,还要保证 so 文件和依赖它的宿主代码是 API 兼容的(严格上必须要求版本一直,至少做到向前兼容)。如果不需要一般插件那样考虑升降级问题,那也必须做到 so 文件和 APK 包版本是一致的:宿主下载相应版本的 so 文件后,安装到指定的版本路径;宿主版本升级后必须再次下载新版本的 so 文件而不能受到存量旧版本 so 文件的干扰(如果需要做到动态升降级,还需要保留最近一两个版本的存量 so 文件,用于 fallback 逻辑需要)。
版本控制除了解决插件的 API 兼容问题,还可以实现“即时吊销”策略。设想我们发布了某一个版本宿主 APK 和与之对应的 so 插件包,而这个版本的 so 是有 Bug 的可能导致 APP 崩溃。通过版本控制流程,我们可以在服务端禁用这个版本的 so 插件,从而使客户端进入“so 插件不可用”的逻辑,而不至于执行有问题的代码。(如果 so 插件支持动态升降级,还可以配置让客户端强制更新到 fix 插件版本,或者 fallback 回没有问题的存量旧版。)
从框架设计上,版本控制涉及动态化的 Update 和 Install 两个环节,具体实现代码可以参考 Updater#doUpdatePolicy() 和 Installer#isInstalled()。
abi 兼容性是 so 插件特有的动态化问题,除了考虑 so 插件是否安全之外,我们还需要检查 so 插件包里的 so 库 abi 信息是否与宿主目前运行时的 abi 一致。考虑这么一种情况:宿主 APK 里面内置了 ARM32 和 AMR64 两种 so 文件,同样插件包里也内置这两种 so 文件,当宿主 APK 安装在 ARM32 的设备上,动态加载 so 插件的时候,我们必须只解压并加载相应 AMR32 的 so 插件,对于 ARM64 的设备也是同样的道理。也就是说:同样的 APK 宿主,同样的 so 插件,安装在不同 abi 设备上时,动态化框架的插件处理行为是不一样的。
这个问题也可是说是版本控制问题上面的一个分支问题。考虑到框架的完备性,框架自身应该能自动设别和处理好 abi 兼容问题,而不是通过 so 插件的打包流程来规避这个问题(容错)。
侵入性问题也是 so 插件特有的问题,这个问题跟 Android Framework 加载 so 库的具体方式有关。Framework 一般不让用户直接通过 dlopen 函数加载动态链接库,而是封装了以下两种加载 so 库的方式(实际上第二种最终也是需要通过 libName 找到具体的 so 文件路径,再通过文件路径加载 so 库,与第一种方式殊途同归):
1 | public final class System { |
通常情况下,我们是通过 方式二
以 System.loadLibrary("xxx")
的方式来加载 so 文件 libxxx.so
,而将 so 文件动态化之后,我们需要将 so 文件安装到内部安全路径,在通过 方式一
以 System.load("{安全路径}/libxxx.so")
的方式来加载。这种方案是大部分 so 动态化项目采用的方案,一直以来也都能稳定工作,不过我们也在这个方案里发现了不少麻烦。
采用 方式一
作为 so 动态化的方案,意味着代码里要写死 System.load("{安全路径}/libxxx.so")
。这样一来,首先我们在代码调节阶段就蛋疼了,Native 代码在开发阶段完全可以用传统的内置方案进行调试,在集成阶段再按动态化的方案打包,这也就意味着我们必须频繁地在 方式一
和 方式二
直接来回修改,代码侵入性问题非常严重。然而这还不是最麻烦的问题,对于第三方的 SDK 项目的动态化问题,如果 SDK 项目本身的 so 库是以 方式二
的方式加载(正常的开发方式,对于一些自身就带有 so 文件下载逻辑的 SDK 项目,则很可能是以 方式一
加载的,这种情况下反而问题不大),则可能需要借助 ASM 这种“曲线救国”的方式来把 SDK 项目里 so 加载的相关代码修改成 方式一
;或者选择在准备好 so 插件之后立即以 方式一
把插件里的所有 so 文件加载进宿主,这样可以兜住插件里 方式二
的加载代码(如果目标 so 库已经加载过一次,则 方式二
加载代码变成一个空实现)。
解决 so 动态化的 System#load 代码侵入问题,要借鉴 Android 热修复技术方案的思路:按 方式二
,即通过 System#loadLibrary("xxx" )
加载 so 库, Android Framework 会遍历当前上下文的 ClassLoader 实例里的 nativeLibraryDirectories 数组,在数组里所有的文件路径下查找文件名为 libxxx.so
的文件,所以我们的解决思路就是在安装好 so 插件之后,将其所在的内部安全路径注入到这个 nativeLibraryDirectories 数组里,即可实现通过 方式二
加载。(思路虽然简单清晰,不过 在实际应用中还是有不少问题,以来在具体的解决方案中进行详细说明。)
具体注入代码实现可以参考一下 TinkerLoadLibrary#installNativeLibraryPath(ClassLoader, File)。以下篇幅 对 so 动态化的方案和具体技术细节给出我们的分析和答案。
当我们调用 System#loadLibrary("xxx" )
后,Android Framework 都干了些了啥?
简单来说,Android 的 so 加载流程,大致可以分为以下四个环节:
大致流程示意图如下:
具体流程以及方法调用链这里不做深入讨论,有兴趣这里推荐老罗的 Dalvik虚拟机JNI方法的注册过程分析 一文。根据这个流程以及上面提到的“加载代码侵入问题”,按照 System.loadLibrary("xxx")
加载代码和 JNI 方法相关类(以下统称 JNI 代码)所在的 ClassLoader 实例不同,so 动态化技术可以分为“JNI 代码隔离”和“JNI 代码内置”两种解决方案。
顾名思义,就是将涉及到的 JNI 代码拆解到一个独立的模块,一同打包进 so 插件包里。运行时动态加载 so 库的时候,先给 so 插件创建一个插件 ClassLoader,在插件 ClassLoader 内部执行“so loading”和“jni calling”。代码隔离方案的优点是是能够做到插件模块编译隔离,其他模块的代码无法 Reference 插件里面的相关 JNI 方法,不容易干扰 JNI 调用的生命周期,后续维护成本低(这也是一般的插件化方案需要做到的目标)。同时缺点也是非常明显的:根据项目历史包袱的具体情况,模块拆解成本可能比动态化改造的收益还大。因此,代码隔离方案比较适合新增的 Native 模块,一开始就奔着动态化、延迟加载的方向去。
考虑到拆解 JNI 模块的技术成本,可以考虑先单独把 so 文件单独打包进插件包,JNI 代码保留在宿主代码内部,so 插件共用宿主的 ClassLoader 实例,“so loading”和“jni calling”依旧保留在宿主内部执行。这种“偷懒”的 JNI 代码内置方案相对于隔离方案来说改造难度要小得多,相应地由于没有把代码拆解干净,非常容易造成代码污染问题,后续维护成本大。考虑到时间成本,我相信大部分项目只能选择 JNI 代码内置方案。毕竟代码污染问题,可以通过 Code Review、Lint 静态检查等方式来加强“代码准入”门槛,缓解问题。
这里需要特别强调的是,相比于代码隔离方案,JNI 代码内置方案有个特有的技术问题不得不解决:向 nativeLibraryDirectories
注入 so 插件路径带来的 集合并发修改 问题。由于 nativeLibraryDirectories 的具体实现是一个 ArrayList 实例,其元素读写操作自身是不保证线程安全的,而我们在 Worker 线程加载 so 插件的环节最后需要将新的 so 文件路径注入到 ArrayList 集合里,如果这时候刚好有另一个线程因为执行“so loading”操作而正在遍历集合元素,则会抛出 ConcurrentModificationException(ArrayList 内部实现)。
解决并发修改问题的思路有两种:
给“so loading”和“ so 文件路径注入”这两种操作同时上锁,锁的实例是 so 相关的 ClassLoader 实例。在所有“so loading”操作之前(比如冷启动初始化环节)就预先注入预留好的 so 文件路径。
思路 1 比较简单合理,不过加锁的操作需要“侵入”其他所有相关的 System.loadLibrary("xxx")
调用,同样容易造成代码污染问题;而思路 2 总感觉有点违反程序设计的一般原则(有些 so 插件可能基本用不上,犯不着在一开始就把其路径注入进来),具体取舍要看项目实际情况。作为补充,思路 1 可以再优化一下:为了避免加锁操作带来的代码污染,可以绕个弯子在编译阶段通过 ASM 手段给自动给所有“so loading”上锁;或者在往 ClassLoader 注入路径的时候,不要在原有的 nativeLibraryDirectories 集合上做修改,而是重新 new 一个 List 实例把所有的路径都拷贝到新集合上,最后再整体塞回去 ClassLoader,避免并发修改异常,代价是允许出现并发读脏数据问题(不至于崩溃)。
我们这两个思路都有尝试,实际投产用的是思路 2,除了污染问题之外,主要是因为下面谈到的“dlopen 问题”。
dlopen
是 Native 开发比较熟悉的一个函数,其功能是以指定模式加载指定的动态链接库(使用 dlclose 来卸载打开的库)。实际上,Android Framework 加载 so 库的 System.loadLibrary("xxx")
调用,最后也是通过 dlopen
来实现 ,大致的调用路径如下:
1 | Sysytem#loadLibrary --> Sysytem#load --> Runtime#nativeLoad |
在 NDK 开发中,如果我们有两个 so 文件:libxxx.so 和 liblog.so(后者是基础库,前者需要依赖后者的 API),xxx 需要动态链接 log,具体体现在 CMake 配置如下:
1 | ... |
则当我们调用 System.loadLibrary("xxx")
的时候,Android Framework 会通过上面提到的调用链最终通过 dlopen 加载 libxxx.so 文件,并接着通过其依赖信息,自动使用 dlopen 加载 liblog.so(第二步没有返回 System#load,而是直接在 Native 层面执行)。对于熟悉 Native 开发的同学来说可能司空见惯,但对于只在第三方 SDK 里接触过 so 文件的同学来说,应该不太知道着这一点。然而恰恰正是这一点,给 so 动态化添加了非常大的困难,也让我们在具体的实践项目中吃了很大的亏。
根据项目经验,现在无论是插件化技术,或者是热修复技术,里面关于动态加载 so 文件的技术方案应该相当成熟,所有的坑都踩得七七八八,就算有没有解决的坑,那应该也不不会严重到影响项目方案可行性的地步。所以一开始,我们把动态化方案主要的风险评估放在模块代码拆解方面,而完全没有担心技术风险。实际上,在 Android N 以前,只要你将 libxxx.so 和 liblog.so 所在的文件目录路径都注入到当前 ClassLoader 的 nativeLibraryDirectories 里,则在加载 so 插件的时候,这两个文件都能正常被找到。而从 N 开始情况就不一样了: libxxx.so 能正常加载,而 liblog.so 会出现加载失败错误。具体异常如下:
1 | E/ExceptionHandler: Uncaught Exception java.lang.UnsatisfiedLinkError: dlopen failed: library "liblog.so" not found |
其主要原因是,Android Native 用来链接 so 库的 Linker.cpp dlopen 函数 的具体实现变化比较大(主要是引入了 Namespace 机制):以往的实现里,Linker 会在 ClassLoder 实例的 nativeLibraryDirectories 里的所有路径查找相应的 so 文件;更新之后,Linker 里检索的路径在创建 ClassLoader 实例后就被系统通过 Namespace 机制绑定了,当我们注入新的路径之后,虽然 ClassLoader 里的路径增加了,但是 Linker 里 Namespace 已经绑定的路径集合并没有同步更新,所以出现了 libxxx.so 文件能找到,而 liblog.so 找不到的情况。
至于 Namespace 机制的工作原理了,可以简单认为是一个以 ClassLoader 实例 HashCode 为 Key 的 Map,Native 层通过 ClassLoader 实例获取 Map 里存放的 Value(也就是 so 文件路径集合),具体代码可以参考 ClassLoaderFactory#createClassLoader()。
我之前琢磨着,Tinker 之所以一直没有把 dlopen 问题暴露出来,主要是因为 Tinker 是热修复框架,补丁插件里需要的 liblog.so 文件,往往在宿主里本来就有内置一份,所以只会导致热修复部分失效,而不会出现 liblog.so 找不到问题。而实际上好巧不巧,Tinker 在解决 Android N 的混合编译带来的热修复失败问题时,在往 ClassLoader 注入插件 so 文件路径的时候,会创建一个新的 AndroidNClassLoader 实例用来替换 APP 自身的 ClassLoader,这个替换的操作刚好一并兜住了 dlopen 问题。至于其他插件化框架里为何没有提到这个问题,大概是因为一般适合动态化改造的插件都比较轻量,一般不会有 Native 代码(就算有也往往没有 so 依赖)。
解决 dlopen 问题主要有以下几个思路:
自定义 System#load,加载 libxxx.so 前,先解析 libxxx.so 的依赖信息,再递归加载其依赖的 so 文件(推荐参考开源方案 SoLoader)。自定义 Linker,完全自己控制 so 文件的检索逻辑(推荐参考开源方案 ReLinker)。类似 Tinker,在合适的时机替换 ClassLoader 实例(这是我们现在投产的方案)。
上面提到的都是 so 动态化方案中的具体技术难题,剩下的都是一些繁琐的项目问题了(技术债务),比如上面提到的 so 依赖分析。想要把 so 动态化技术应用到 APK 的瘦身项目中来,除了分析哪些 so 文件体积占比比较大之外,最好的做法是将其依赖的所有 so 文件一定挪到插件包里。怎么了解 APK 里所有 so 文件具体的依赖信息呢?根据 so 文件模型手撸代码解析依赖信息固然可行,不过那都是大神干的活,吾等平凡之辈还是选择站在巨人的肩膀上。
这里推荐一款 Google 开源的 APK 解析工具 android-classyshark,除了提供分析 APK dex/so 依赖信息之外,它还提供了 GUI 可视化界面,非常适合快速上手。
JNI 方法需要在加载完成相应的 so 库才能正常调用,所以有不少开发选择将 System#loadLibrary("xxx" )
之类的代码写在 JNI 类的静态代码块,以保证在访问 JNI 之前一定会先完成 so 库加载。不过这实际上非常不“Best Practice”:一方面,加载 so 原本就属于一种动态化技术,其自身就存在失败的可能性,而且 Native 开发在 Android 上一直存在诸多“疑难杂症”(推荐参考一下这篇文章 The Perils of Loading Native Libraries on Android),最好的办法是考虑所有 so 加载和 JNI 方法调用失败的可能性;另一方面,加载 so 文件本身就有些许性能损耗,在静态代码块中加载会加剧性能问题。最麻烦的是,so 动态化改造之后,如果项目后续开发中有人不小心在 so 插件尚未安装完成之前引用了相关的 JNI 类(比如访问静态方法),哪怕没有发生实际的方法调用,也会导致 JNI 类提前被 ClassLoader 加载,进而提前触发 System#loadLibrary("xxx" )
逻辑,触发 Crash。
对于项目已有的 JNI 代码,如果存在“静态代码块加载 so 问题”,则在改造成动态化的时候,最好将相关加载代码挪出静态代码块,并且增加 so 加载失败时候的 onFail 逻辑,确保所有 so 加载和 JNI 方法调用都不会出现崩溃问题。
这也是我目前比较头疼的问题,由于采用了“JNI 代码内置方案”,没有对 JNI 代码进行编译隔离,非常容易导致后续代码维护过程中,在不正确的生命周期里访问了动态化 so 相关的 JNI 方法,增加 Crash 的风险。
按照以往的动态化项目经验,“比较稳定,代码变化不大,模块边界比较内聚”的业务比较适合动态化改造,所以 so 动态化应该优选则这种类型的模块,无论是改造成“JNI 代码隔离方案”,还是后续的维护成本,都相对要小许多。对于那些代码耦合比较严重,版本迭代非常活跃的业务模块,这是一个典型的“在高速行驶的火车上更换引擎”的问题:在动态化改造的同时,FT 代码还在并行迭代,势必会产生许多冲突;对于耦合比较严重的代码,考虑投入产出比的话一般都会选择“JNI 代码内置”方案,没有对 JNI 代码进行编译隔离,所以非常容易导致 Crash;改造完成后,后续 FT 代码变动频繁,后续代码维护压力大,而且可能是。
目前我觉得比较靠谱的处理方案是从项目管理流程上找突破点,主要方向最好还是让 FT 开发自己负责自己模块的动态化改造工作,降低维护成本(考虑到业务团队跟质量团队之间绩效目标的冲突,可能难以推动)。同时需要尽量根据项目的实际需要完善动态化框架以及相关配到的知道文档,降低 FT 的接入成本。作为辅助,还需要给容易产生代码冲突的地方加上相应的静态检查 Case,以便及时发现问题。
踩了上面一系列的坑,眼看着动态化技术方案完善得七七八八了,实际上 我们才刚刚开始而已!
首先,怎么编译出 so 插件包也是个技术活,这一点要根据具体的项目情况选择合适的方案(我们选用的是 Gradle 插件在 PackageApplication 阶段抽取目标资源文件)。这个是一个 CI 问题,换句话说就是我们需要一个稳定灵活的流水线,用于稳定编译我们指定版本的 so 插件包,而不是每次都通过非常手工、笨拙的方式编包。其次,插件包编译之后,不应该通过手工的方式把文件上传到后端,在填写相关的版本、依赖等配置信息。这是一个 CD 问题(Continuous Deployment),我们应该采用自动化的手段(哪怕只是脚本),在集成阶段之后收集需要的配置信息,自动上传到一个内部环境的管理平台(平台上我们可以查看每个版本的数据),在 Test/Release 阶段根据需要将指定版本的配置信息“一键导入”到测试、预发布环境,每个环节上都要尽量避免人工操作。
因此,从工程管理的角度来看,一个完备的动态化方案,必须涵盖集成、部署、加载框架三个流程的内容,而前面的两点是大多数动态化项目或者技术文章没有提及到的,往往容易被忽视。
由于一些总所周知的原因,包含有动态代码的 APK 包是无法上传到 Play Store 的。不过实际上 Google 不是禁止动态代码,而是禁止绕过 Play 渠道下发未进过审核的动态代码。经过咨询,通过 Play 提供的 APK 拓展资源包 Expansion Files 服务,可以向客户端下发相关插件资源包,没有政策风险(该服务主要是面向游戏客户端,可以想 APK 客户端下发绑定版本的“一个主资源包 + 一个 patch 包”,体积上限个 1G。需要说明的是,用户发布特定版本的 APK 之前必须先绑定资源包,一旦发布就无法修改)。
本文主要是根据我自身实际投产的 Android 动态化项目经验(SDK 插件、动态组件化)以及最近相关的 so 动态化实践,分享一些动态加载 so 库时需要考虑的问题。内容主要包括插件化方案的共同问题、abi 兼容性问题、代码侵入性问题、并发修改问题,以及最重要也最容易忽视的 dlopen 问题。千言万语汇成一句话:
插件有风险,投资须谨慎!
静态程序分析,是指在不运行程序的情况下分析检查代码里存在的问题。这项技术在代码质量、漏洞扫描等领域有广泛的使用。常见分析工具包括 CheckStyle、Lint、FindBugs 等,也有商用的 Coverity。本文主要讲述为我们在 Android 项目 Merge Request 合入检查里对静态程序分析技术的应用,核心内容是增量代码的静态分析方案,至于各种检查工具的对比筛选,请参考文末提供的 References。
微信相关 Android 项目的 DevOps 实践中,我们在合入检查方向已经先后完成了 “需求合法性检查”、“代码冲突检查”、“编译检查”、“编译后 WeTest 自动化 UI 测试” 等检查项目,代码合入检查流程已经比较完善。接下来,我们想尝试在检查流程里加入静态检查环节,看看能不能在 “统一代码风格、提高代码质量” 方面实现一些突破。
传统上,代码风格检查普遍比较依赖于人工的 Code Review,而 DevOps 实践给我们的经验是,代码格式、一般向代码错误等问题交给工具自动化处理比较合适,人工 Review 的主要目的应该是项目方案评审,以及优秀代码学习,不然的话 Code Review 很可能会变成政治任务,流于形式。因此,我们希望借助静态检查工具,先过滤大部分的一般代码问题,再交由人工进行代码设计方面的 Review 或者学习(注意,本文侧重于静态检查工具的使用,至于具体 Code Review 标准、流程请参考其他文献)。
静态检查对 Code Review 起到一个支撑作用:
先由静态检查工具过滤常见的错误,工具无法判断的问题可以给出先 warning log,人工 Review 再根据静态检查的 log,重点排查可疑代码。
不过实际应用上,静态检查工具的接入还是存在许多麻烦的问题,特别是对于一些比较成熟、历史包袱严重的项目。一方面,我们相信有不少人已经尝试过使用一些静态检查工具,这些工具在一些小项目上,经常一下子就能跑出一大堆问题,而对于比较庞大的项目,扫描出太多问题基本相当于扫描不出问题,所以我们不得不想办法让检查工具专注于我们关心的问题。另一方面,一般的静态检查工具的分析过程都比较耗时,少则几分钟,一些需要依赖编译产物的工具耗时可能达到十几分钟(类似 Coverity 这种商业功能需要依赖多维度的数据作为数据流分析的依据,耗时更是可能达到小时的级别),这种量级的时间要求对合入检查流程来说是不可接受的,特别是封版前这种时间十分紧迫的版本阶段,更不用说我们的最终目标是希望在用户本地开发代码阶段就把检查流程添加进来,因此在这个流程上我们需要对静态检查工具的性能提一个非常高的要求。
总结一下问题,现在摆在我们面的主要有 “两个矛盾”:
介绍我们的方案之前,先说一说两个使用得比较普遍的方案:其一,根据代码提交的时间(例如 git 工具就可以检查每一行代码最近的时间),约定一个起始点时间(比如上一个稳定版本)作为 baseline,静态检查工具检查出来的问题,其对应的代码提交记录如果早于这个 baseline 则自动忽略该问题,这样就能从一大堆问题里面筛选出比较 “新鲜” 的问题。其二,选一个稳定版本作为 baseversion,扫描出这个版本所有的问题并把结果记录下来,以后每次静态扫描都和前面 baseversion 保留的记录做比对,屏蔽存量问题(或者直接编写脚本,根据 baseversion 扫描出来的结果,给全部存量问题自动加上 suppression 注释以便后续静态检查在扫描阶段就屏蔽问题)。
公司内部部分静态扫描服务使用的就是以上两个方案之一(比如 CodeCC 使用的是 baseversion 方案),这样做的好是思路清晰接入点也简单,几乎对于所有的静态检查工具都可以使用这些方案,也不用担心静态检查工具版本升级带来的兼容性问题。不过缺点也是显而易见的,首先是依然要重复检查老问题,白白浪费资源,而且有些新问题结果表现可能和老问题一样,导致这些问题会被当成老问题忽视。
再来说说我们现在使用的方案:DevOps 实践中,我们需要计算出用户改了哪些代码或文件(也就是用户提交的 “增量代码”),用来检查用户的行为是否合法(比如改了不允许修改的问题,或者动了别人的代码需要 @owner 过来 Review),因此我们首先想到的是可以直接利用这些增量代码,在静态检查结果中匹配出增量代码带来的问题,这样虽然无法一下解决检查效率的问题,但是也能保证匹配出来的问题大概率是和用户改动的代码相关的。再做进一步思考,既然我们已经得到代码的增量数据,我们是不是可以直接对这部分增量的代码做静态检查?这样既不用重复检查那一大堆成年老问题,也可以直接暴露出增量代码带来的问题。答案是肯定的,我们最终采取了这两种方式结合的思路:
直接对增量代码做检查得到一批问题报告,再从中匹配出增量代码带来的问题。
静态检查的效率,一方面跟扫描的文件数量相关,另一方面也受工具自身扫描算法、扫描内容、检测规则的粒度、规则的数量等影响。想要提高效率,一方面我们需要尽量减少输入的文件数,而我们上面提到的增量检查思路刚好把这个方面的问题做到最优解。另一方面,至于扫描算法,自己开发更加高效的检查工具显然不太现实,所以我们把目光放到扫描内容和检测规则,好在现在主流的静态检查工具,其检查规则甚至检查粒度,大都支持用户自定义。简单地说,针对源码类型做扫描的工具比如 CheckStyle 和 Lint,需要经过词法分析、语法分析生成抽象语法树,再遍历抽象语法树跟定义的检测规则去匹配,其工作效率会比较高;而像针对 .class 字节码文件做扫描的工具 FindBugs,需要先编译源码成 .class 文件,再通过 BCEL 分析字节码指令并与探测器规则匹配,效率就会大打折扣(Lint 也支持这种检查方式,这里不做展开)。除了以上谈到的两点外,像 Android 这种 Gradle 项目,如果项目 Module 比较多,Gradle Configure 阶段也会需要比较多的耗时。
实际上,第一个问题的解决思路,已经给现在这个问题指明了一个方向:针对增量代码做检查,既减少输入文件,又降低需要执行检查任务的 Module 数。万事俱备,接下来的事就是选用合适的静态检查工具了。
对于我们的项目,目标是 “统一代码风格、提高代码质量”。统一风格方面,CheckStyle 当仁不让, 它支持直接对代码源文件进行扫描,并且内置许多成熟的 Style Guides/Conventions 方案(比如 Google/Sun/Oracle),而且自定义规则也非常简单,完全可以自定义自己的代码格式和变量命名规则。剩下的就是如何提高代码质量,我们选用的是 Lint,理由非常简单,Lint 是 Android 官方深度定制工具,和 Android 项目相性最好,功能极其强大,且可定制性和扩展性以及全面性都表现均超乎我们期待。平时编写代码过程中,Android Stuido 智能标注的各种红色(Error)、黄色(Warning)警告,大部分都是 Lint 检查的结果(所以有事没事推荐大家多按 F2 试试,自动跳到下一处 Lint 检查出问题的地方)。除了跟 IDE/Gradle 插件相结合得很好之外,Lint 也像 CheckStyle 一样,支持直接对代码源文件、甚至 XML 资源文件进行检查,不需要计算依赖或者 API References,效率比较理想。而且 Lint 自定义检查规则的功能也非常强大,几乎覆盖对大部分 Java 代码、XML 资源格式的检查。
我们的整体方案是:
在计算代码增量信息方面,我们需要解决以下几个问题:
我们计算代码增量信息的方案,全靠 git diff
命令:
这里先简单介绍一下我们方案需要用到的这几个参数的作用:
name-only
:只显示修改文件的名字,不显示内容,这个参数分别刚好满足上面提到的方案的 “文件粒度” 和“文件修改行数粒度”。-cached
:只显示被 staged 过的文件(也就是已经被 add 到 git index 里),计算本地修改代码的时候,需要通过这个参数计算 “已 staged” 和“未 staged”两种文件的综合数据。-diff-filter=ACMR
: 显示哪类文件:Added (A)、Copied (C)、 Modified (M)、Renamed (R),具体配置请参看官方文档。-ignore-space-at-eol
: 忽略行尾空格或者换行符等修改信息,这里额外说明一下,MR 计算代码修改信息用的也是 git diff 工具而且默认不带这个参数,而 IDEA 的 Annotate 默认是带这个参数的,所以有时候会看到有人提交 MR 显示修改了大量代码(比如批量代码格式化导致修改文件换行符),而当事人本地 IDEA 又看不出自己改了代码。M100%
:这是一个阈值,用来计算前后两个不同名字的文件,内容相似度达到哪个百分比就认为这是一个被 Renamed (R) 的文件。关于参数解释,这里推荐一个工具 explainshell.com,不在赘述。现在回答上面提到的几个问题。
对第一个问题,通过 git diff --diff-filter=ACMR --ignore-space-at-eol -M100%
和 git diff --cached --diff-filter=ACMR --ignore-space-at-eol -M100%
两个命令组合起来,可以综合计算出本地变动代码的增量信息。至于第二个问题,可以先通过 git merge-base <main_branch_revision> HEAD
计算出当前分支跟主干分支之间的共同节点 merge_base_revision,再通过 git diff <merge_base_revision> HEAD
,就可以计算出当前开发分支从主干分支拉出来之后的代码变动情况。
这个方案里最难也最容易被忽略的是第三个问题,不像第一个问题里只要分别计算 staged 和 unstaged 两种文件再加起来就完事了,这里的本地的修改数据不是简单的叠加问题:用户修改了个文件 X,代码提交后,开发分支和主干之间就有一份确定的文件 X 的修改信息 A,这时候用户在本地继续修改文件 X,那本地修改记录里文件 X 也有另一份修改信息 B,这时候 B 跟 A 的修改信息是完全冲突的,我们要的是用户修改文件后本地文件最终状态跟主干之间的代码差异 C,而 A 跟 B 都是错误的信息,而且很明显 C ≠ A + B。
为了解决第三个问题,我们前后设计了两套方案:
git add -A && git commit
自动提交本地修改代码,然后计算 git merge-base <main_branch_revision> HEAD
,最后 git reset --soft HEAD~1
把自动提交撤销掉。更详细的操作,还可以先计算修改文件哪些是 staged 的,reset 之后需要把 staged 状态恢复,全面还原文件状态。方案 1 的好处就是逻辑清晰,但是提交和撤销动作涉及本地文件修改,容易出现文件修改冲突破坏现场。方案 2 逻辑和算法都比 1 要复杂得多,好在经过几次迭代后我们证明这个路子是可行,现在稳定投产中。
作为补充说明,git diff
命令输出的格式不太适合直接参与后面的计算工作,需要先转换成程序友好的格式(比如 JSON),这里推荐一款基于 Python 的 git diff 解析工具:git-diff-parser。项目中我们采用的是 Gradle 插件,所以也用 Groovy 实现了类似的解析工作。假设我们修改了 MainActivity.java
文件,新增了 basepacks.txt
文件,git diff
解析前格式是:
1 | diff --git a/app/src/main/java/com/example/app/MainActivity.java b/app/src/main/java/com/example/app/MainActivity.java |
经过解析之后变成:
1 | [ |
具体代码请参考文末提供的代码仓库。
在增量检查的具体实现上,我们采用的是自定义 Gradle 插件,增加了 :checkIncremental
任务来执行增量的检查任务。其中 CheckStyle 检查的实现是基于 Gradle Api 提供相关 Checkstyle Task,这个本身就支持增量检查,直接配置输入文件就好。Lint 的增量检查就要复杂得多,主要是基于 Android 官方的 AGP (Android Gradle Plugin) 插件提供的 com.android.tools.lint:lint-gradle
库进行实现,以下介绍几个关键的技术点。
Android Lint 的工作过程比较简单,由一个基础的 Lint 过程由 Lint Tool(检测工具),Source Files(项目源文件) 和 lint.xml(配置文件) 三个部分组成:Lint Tool 读取 Source Files,根据 lint.xml 配置的规则(issue)输出结果,如下图:
Android 项目中,一般我们有三种方式运行 Lint 检查:命令行、IDEA 的 Inspections 检查功能、Gradle Lint 任务,他们都由 AGP Android Lint 提供,并由 Android 官方进行维护,虽然检查入口各不相同,但是底层都是同一套 Lint API 实现(提供 Lint 检查实现的 lint-api.jar 和封装好一些常用检查规则的 lint-check.jar,三种工作方式都是基于这两个类库实现)。
届于 Lint API 涉及的类库比较复杂,这里不做深入讨论,主要介绍一下几个比较关键的 API:
LintDriver
: 三种工作方式最后都通过 LintDriver#analyse() 执行实际上的检查工作。IssueRegistry
:管理 Lint 检查规则,配合 lint.xml 使用。Android 有内置的 BuiltinIssueRegistry,用户自定义检查的话,需要重写该类。LintRequest
:执行 Lint 检查时的一个请求类,封装好了需要检查的文件内容,我们需要实现的增量检查,也是要从这里下手。LintClient
:Lint 客户端,集成 Lint 检查的操作、配置,对应某一种具体的工作方式,比如 LintCliClient 对应命令行方式,LintIdeClient 对应 IDEA 的 Inspections,LintGradleClient 对应 Gradle Lint Task。用伪代码表示的话大概是:
1 | def registry = new IssueRegistry() |
自定义检查规则,就是要自定义各种检查 Detector 类,具体可以参考官方的指导文档 Writing a Lint Check 或者美团的几篇 Lint 实践文章 美团 Lint。老实说,这方面官方给出的 Example 并不是很详细,具体怎么写还是要靠自己去看官方 Lint API 的源码,以及参考别人的开源代码(如果你比较熟悉 Visitor 访问者模式,或者写过 ASM 插件,应该比较容易上手)。
这里给出一个我们自己自定义的检查规则作参考:
1 | // 检查 Log 的使用规范 |
上面提到,“我们需要实现的增量检查,需要从 LintRequest 下手”,通过重写 LintClient 中的 createLintRequest 方法,传入我们需要增量检查的文件,以下给出关键的代码,具体的实现细节,请参考我们的代码库。
1 | class LintToolClient extends LintCliClient { |
上面提到有三种 Lint 的工作方式,我们采用的是扩展第三种 Lint Gradle 方式(因为这种方式能直接复用现成的 lint.xml 配置文件和 Lint Output 报告格式),最终整体的工作流程是:
除了我们采用的第三种 Lint Gradle 方式,这里补充说明一下第二种 IDEA 的 Inspections 功能:通过 “IDEA - Analyse - Inspect Code” 可以迅速针对一个指定的文件做 Lint 检查。如下:
经过挖掘,我们发现其实现的关键代码在 LintIdeClient.java。通过 Inspections 方案,我们能直接对指定文件进行 Lint 检查,而不需要依赖于 Gradle 环境,这是 Lint 增量检查的最佳方案。不过考虑到 IDEA 版本之间的兼容性问题,而且我们还需要将检查工作合入到 DevOps 自动化流程里,所以最终还是选择了 Gradle Lint 方案。
最后,关于具体的 Lint 实现有一点需要补充说明:Lint API 25 到 26 之间,无论是 API 接口还是具体的实现,变化都非常大,所以各位参考别人的具体实现代码的时候,一定要先分清当前的 API 版本是多少。
没有流程图的方案是没有灵魂的,如下:
以代码仓库的 Demo 项目为例,如果执行一遍默认的 :app:clean :app:lint
检查任务,耗时 Configure + 检查任务整体耗时大概在 10s 左右,如下:
接入增量 Lint 方案后,耗时已经能压缩到 1s 左右:
![:app:clean :app:checkLint time consumed
即使加上增量的 CheckStyle 检查任务,再最终补上一个用来做检查结果报告的 checkReport
任务,整体增量检查耗时也能稳定在 1s:
在实际项目 MR 合入检查流水线里的应用效果如下:
在 MR 代码合入检查的静态检查环节上,我们目前一共实现了 CheckStyle、Lint、文件格式(LF/CRLF 换行符问题)、非法文件修改(文件权限)四种检查内容,其中 Lint 增量带来的收益最明显,时间成本从原本的几分钟、十几分钟级别下降到几秒到几十秒的级别(通常只要在封版前涉及大量代码修改的 MR 才需要几十秒的耗时),已经基本满足了我们 DevOps 合入检查的要求(考虑到静态检查环节是我们合入检查几个并行的 Stages 之间耗时最小的一个,可以说相当于没有时间成本)。而且除了时间成本之外,Lint 自定义检查的功能相当于给我们的平台提供了一种定制性比较强的检查工具,比如 Dark Mode 对 XML 的颜色值有使用规范,通过自定义 Detector 可以很轻松得检查每一个新增 XML 文件的 color 属性。
本文主要以介绍静态检查整体的应用方案为主,以及分享方案落地流程里一些问题,主要是一己的经验之谈。如果你希望了解现有静态检查工具的对比和应用,这方面市场上已经有大量的科普和评测文章请自行检索,如果你希望试用各种检查工具,这里推荐一下公司内部的静态检查服务 CodeCC 和 CodeDog,他们都有详细的使用文档。又或者你想研究 Lint API 具体的工作细节,这里推荐一下美团技术团队编写的几篇 Lint 相关技术文章 美团 Lint。
Where there is an Android App, there is an Application context.
没毛病,扎心了。App运行的时候,肯定是存在至少一个Application实例的。同时,Context我们再熟悉不过了,写代码的时候经常需要使用到Context实例,它一般是通过构造方法传递进来,通过方法的形式参数传递进来,或者是通过attach方法传递进我们需要用到的类。Context实在是太重要了,以至于我经常恨不得着藏着掖着,随身带着,这样需要用到的时候就能立刻掏出来用用。但是换个角度想想,既然App运行的时候,Application实例总是存在的,那么为何不设置一个全局可以访问的静态方法用于获取Context实例,这样以来就不需要上面那些繁琐的传递方式。
说到这里,有的人可能说想这不是我们经常干的好事吗,有必要说的这么玄乎?少侠莫急,请听吾辈徐徐道来。
这再简单不过了。
1 | public static class Foo1 { |
这种方式应该是最常见的获取Context实例的方式了,优点就是严格按照代码规范来,不用担心兼容性问题;缺点就是API设计严重依赖于Context这个API,如果早期接口设计不严谨,后期代码重构的时候可能很要命。此外还有一个比较有趣的问题,我们经常使用Activity或者Application类的实例作为Context的实例使用,而前者本身又实现了别的接口,比如以下代码。
1 | public static class FooActivity extends Activity implements FooA, FooB, FooC { |
这段代码是我许久前看过的代码,本身不是什么厉害的东西,不过这段代码段我至今印象深刻。设想,如果Foo的接口设计可以不用依赖Context,那么这里至少可以少一个this
不是吗。
现在许多开发者喜欢设计一个全局可以访问的静态方法,这样以来在设计API的时候,就不需要依赖Context了,代码看起来像是这样的。
1 | /* |
这样在整个项目中,都可以通过Foo#getContext()
获取Context实例了。不过目前看起来好像还有点小缺陷,就是使用前需要调用Foo#setContext(Context)
方法进行注册(这里暂不讨论静态Context实例带来的问题,这不是本篇幅的关注点)。好吧,以我的聪明才智,很快就想到了优化方案。
1 | /* |
不过这样又有带来了另一个问题,一般情况下,我们是把应用的入口程序类FooApplication
放在App模块下的,这样一来,Library模块里面代码就访问不到FooApplication#getContext()
了。当然把FooApplication
下移到基础库里面也是一种办法,不过以我的聪明才智又立刻想到了个好点子。
1 | /* |
这样以来,就不用把FooApplication
下移到基础库里面,Library模块里面的代码也可以通过BaseApplication#getContext()
访问到Context实例了。嗯,这看起来似乎是一种神奇的膜法,因吹斯听。然而,代码写完还没来得及提交,包工头打了个电话来和我说,由于项目接入了第三发SDK,需要把FooApplication
继承SdkApplication
。
……
有没有什么办法能让FooApplication
同时继承BaseApplication
和SdkApplication
啊?(场面一度很尴尬,这里省略一万字。)
以上谈到的,都是以前我们在获取Context实例的时候遇到的一些麻烦:
那么,有没有一种方式,能够让我们在整个项目中可以全局访问到Context实例,不要提前注册,不会污染Application类,更加不会引发静态Context实例带来的内存泄露呢?
回到最开始的话,App运行的时候,肯定存在至少一个Application实例。如果我们能够在系统创建这个实例的时候,获取这个实例的应用,是不是就可以全局获取Context实例了(因为这个实例是运行时一直存在的,所以也就不用担心静态Context实例带来的问题)。那么问题来了,Application实例是什么时候创建的呢?首先先来看看我们经常用来获取Base Context实例的Application#attachBaseContext(Context)
方法,它是继承自ContextWrapper#attachBaseContext(Context)
的。
1 | public class ContextWrapper extends Context { |
是谁调用了这个方法呢?可以很快定位到Application#attach(Context)
。
1 | public class Application extends ContextWrapper { |
又是谁调用了Application#attach(Context)
方法呢?一路下来可以直接定位到Instrumentation#newApplication(Class<?>, Context)
方法里(这个方法名很好懂啊,一看就知道是干啥的)。
1 | /** |
看来是在这里创建了App的入口Application类实例的,是不是想办法获取到这个实例的应用就可以了?不,还别高兴太早。我们可以把Application实例当做Context实例使用,是因为它持有了一个Context实例(base),实际上Application实例都是通过代理调用这个base实例的接口完成相应的Context工作的。在上面的代码中,可以看到系统创建了Application实例app后,通过app.attach(context)
把context实例设置给了app。直觉告诉我们,应该进一步关注这个context实例是怎么创建的,可以定位到LoadedApk#makeApplication(boolean, Instrumentation)
代码段里。
1 | /** |
好了,到这里我们定位到了Application实例和Context实例创建的位置,不过距离我们的目标只成功了一半。因为如果我们要想办法获取这些实例,就得先知道这些实例被保存在什么地方。上面的代码一路逆向追踪过来,好像也没看见实例被保存给成员变量或者静态变量,所以暂时还得继续往上捋。很快就能捋到ActivityThread#performLaunchActivity(ActivityClientRecord, Intent)
。
1 | /** |
这里是我们启动Activity的时候,Activity实例创建的具体位置,以上代码段还可以看到喜闻乐见的”Unable to start activity”异常,你们猜猜这个异常是谁抛出来的?这里就不发散了,回到我们的问题来,以上代码段获取了一个Application实例,但是并没有保持住,看起来这里的Application实例就像是一个临时变量。没办法,再看看其他地方吧。接着找到ActivityThread#handleCreateService(CreateServiceData)
,不过这里也一样,并没有把获取的Application实例保存起来,这样我们就没有办法获取到这个实例了。
1 | public final class ActivityThread { |
我们可以看到,这里创建Application实例后,把实例保存在ActivityThread的成员变量mInitialApplication
中。不过仔细一看,只有当system == true
的时候(也就是系统应用)才会走这个逻辑,所以这里的代码也不是我们要找的。不过,这里给我们一个提示,如果能想办法获取到ActivityThread实例,或许就能直接拿到我们要的Application实例。此外,这里还把ActivityThread的实例赋值给一个静态变量sCurrentActivityThread
,静态变量正是我们获取系统隐藏API实例的切入点,所以如果我们能确定ActivityThread的mInitialApplication
正是我们要找的Application实例的话,那就大功告成了。继续查找到ActivityThread#handleBindApplication(AppBindData)
,光从名字我们就能猜出这个方法是干什么的,直觉告诉我们离目标不远了~
1 | public final class ActivityThread { |
我们看到这里同样把Application实例保存在ActivityThread的成员变量mInitialApplication
中,紧接着我们看看谁是调用了handleBindApplication
方法,很快就能定位到ActivityThread.H#handleMessage(Message)
里面。
1 | public final class ActivityThread { |
Bingo!至此一切都清晰了,ActivityThread#mInitialApplication
确实就是我们需要找的Application实例。整个流程捋顺下来,系统创建Base Context实例、Application实例,以及把Base Context实例attach到Application内部的流程大致可以归纳为以下调用顺序。
ActivityThread#bindApplication (异步) –> ActivityThread#handleBindApplication –> LoadedApk#makeApplication –> Instrumentation#newApplication –> Application#attach –> ContextWrapper#attachBaseContext
源码撸完了,再回到我们一开始的需求来。现在我们要获取ActivityThread的静态成员变量sCurrentActivityThread。阅读源码后我们发现可以通过ActivityThread#currentActivityThread()
这个静态方法来获取这个静态对象,然后通过ActivityThread#getApplication()
方法就可能直接获取我们需要的Application实例了。啊,这用反射搞起来简直再简单不过了!说搞就搞。
1 | public class Applications { |
这样以来, 无论在项目的什么地方,无论是在App模块还是Library模块,都可以通过Applications#context()
获取Context实例,而且不需要做任何初始化工作,也不用担心静态Context实例带来的问题,测试代码跑起来没问题,接入项目后也没有发现什么异常,我们简直要上天了。不对,哪里不对。不科学,一般来说不可能这么顺利的,这一定是错觉。果然项目上线没多久后立刻原地爆炸了,在一些机型上,通过Applications#context()
获取到的Context恒为null。
(╯>д<)╯⁽˙³˙⁾ 对嘛,这才科学嘛。
通过测试发现,在4.1.1系统的机型上,会稳定出现获取结果为null的现象,看来是系统源码的实现上有一些出入导致,总之先看看源码吧。
1 | public final class ActivityThread { |
原来是这么一个幺蛾子,在4.1.1系统上,ActivityThread是使用一个ThreadLocal实例来存放静态ActivityThread实例的。至于ThreadLocal是干什么用的这里暂不展开,简单说来,就是系统只有在UI线程使用sThreadLocal来保存静态ActivityThread实例,所以我们只能在UI线程通过sThreadLocal获取到这个保存的实例,在Worker线程sThreadLocal会直接返回空。
这样以来解决方案也很明朗,只需要在事先现在UI线程触发一次Applications#context()
调用保存Application实例即可。不过项目的代码一直在变化,我们很难保证不会有谁不小心触发了一次优先的Worker线程的调用,那就GG了,所以最好在Applications#context()
方法里处理,我们只需要确保能在Worker线程获得ActivityThread实例就Okay了。不过一时半会我想不出切确的办法,也找不到适合的切入点,只做了下简单的处理:如果是优先在Worker线程调用,就先使用UI线程的Handler提交一个任务去获取Context实例,Worker线程等待UI线程获取完Context实例,再接着返回这个实例。
最终完成的代码可以参考 Applications。
在这里需要特别强调的时候,通过这样的方法获取Context实例,只要在Application#attachBaseContext(Context)
执行之后才能获取到对象,在之前或者之内获取到的对象都是null,具体原因可以参考上面调用流程中的ActivityThread#handleBindApplication
。所以,膜法什么的,还是少用为妙吧。
.dex
文件的时候报错。本来以为这件事情算是告一段落了,没想到自己还是太Naive了。今天早上突然收到了ProGuard开发者发来的一份邮件,Exciting!邮件里谈到了这次的坑出现的真正原因 —— Java源码和字节码(bytecode)里方法的重载(OverLoading)。
在上一篇文章里,我分析到这次问题的原因是
ProGuard工具在混淆注解类类Route.java的时候,把它的两个字段都混淆成a了,按道理应该是一个a和一个b,不知道是不是ProGuard的BUG,还是Route与其他库冲突了。
本来我以为是ProGuard的BUG,把注解类的两个字段都混淆成一样的名字,或者是ProGuard受到别的库的影响才出现了这个BUG。显然,在Java代码里面,是不允许有两个名字相同且形参一样的方法的,哪怕是它们的返回值不同。
1 | public static class Hello { |
这两个方法是无法重载的,IDE会提示错误并且无法编译。虽然现在不少的新编程语言支持这样返回值类型不同的方法重载,但是在Java里行不通,原因也很简单,类似下面的方法立刻就会产生歧义。
1 | public void call() { |
问题的原因虽然只是这么简单,但是其实在.class
文件的字节码(bytecode)里,这样的重载方法是被允许的。为什么呢?简单点说,在字节码里面,对类的文件结构的描述十分严谨,方法调用必须有指定的返回类型,所以像上面那样的调用是不存在的,自然也就不存在产生歧义的问题。
假设现在有这样一个正常的类(上面的示例代码的正常版)。
1 | public class Hello { |
这个类编译成.class
字节码文件后,它的文件结构大概是这样的。
1 | + Program class: com/bilibili/routertest/Hello |
我们重点关心其中的main()V
方法,可以清楚的看到,上面的Java源码中,main方法调用了foo1方法,虽然没有处理返回值,但是在字节码文件结构对应的方法里明确地指明了改该方法的的返回值类型是[Ljava/lang/String
,区别于foo2方法的Ljava/lang/String
。也就是说,字节码里面并不会存在我们上面提到的方法调用的歧义问题,因此可以支持相同形参不同返回值的方法的重载。
对于这个课题感兴趣的同学可以参考这篇出自Oracle的调研文章:Return-Type-Based Method Overloading in Java Blog。
关于造成该问题原因的一些阐述。
解决该问题的一些方法。
overloadaggressively
功能,ProGuard不会对字节码中相同形参不同返回值的方法进行重载(这个功能默认不开启)。以下是ProGuard开发者给出的建议。
1 | Unfortunately, dx has a bug: it crashes on this overloading. Workarounds: |
最后,感叹作者的反馈这么迅速。引用作者的一句原话,It's a fast world!
,西方程序员跑的比谁都快。
ProGuard有个比较坑爹的问题。在开发阶段,我们一般不启用ProGuard,只有在构建Release包的时候才开启。因此,如果有一些API被混淆了会出现BUG,那么在开发阶段我们往往无法察觉BUG,只有在构建发布包的时候才发现,甚至要等发布到线上了才能发现,这种时候解决问题的成本就很大了。
不过今天被ProGuard坑的不是混淆API导致的BUG,这货在之前相当长的一段时间里一直相安无事,最近突然又搞了个大新闻,而且问题排查起来相当蹊跷、诡异。
最近在给项目的开发一个模块之间通讯用的路由框架,它需要有一些处理注解的APT功能,大概是长这个样子的。
1 | "action://sing/", desc = "念两句诗") (uri = |
功能大概是这样的,我先编写一个叫做 PoemAction
,它的业务功能主要是帮你念上两句诗。然后客户只需要调用 Router.open("action://sing/")
就可以当场念上两句诗,这也是现在一般路由框架的功能。其中的desc
没有别的功能,只是为了在生成路由表的时候加上一些注释,说明当前的路由地址是干什么的,看起来像是这样的。
1 | public static class AutoGeneratedRouteTable { |
嗯,代码很完美,单元测试和调试阶段都没有发现任何问题,好,合并进develop分支了。搞定收工,我都不禁想赞美自己的才能了,先去栖霞路玩会儿先。半个小时候突然收到了工头 Yrom·半仙·灵魂架构师·Wang 的电话,我还以为他也想来玩呢,结果他说不知道谁在项目的代码里下毒,导致构建机上有已经有几十个构建任务失败了。我了个去,我刚刚提交的代码,该不会是我的锅吧,赶紧回来。
异常看起来是这样的。
1 | FAILURE: Build failed with an exception. |
这看起来好像是MultiDex的问题啊,但是没道理Debug构建没问题,而只有Release构建出问题了,transformClassesWithMultidexlistForRelease
任务的源码暂时也没有精力去看了,先解决阻塞同事开发的问题要紧。老规矩,使用 二分定位法 挨个回滚到develop上面的commit记录,逐个查看是那次提交导致的,结果还真是我的提交导致的。
难道是开了混淆,导致一些类找不到?但是类找不到只是运行时的异常而已,应该只会在运行APP的时候抛出“ClassNotFoundException”,不应该导致构建失败啊。难道是APT生成的类格式不对,导致Javac在编译该类的时候失败?于是我打开由APT工具生成的AutoGeneratedRouteTable.java
类文件瞧瞧,发文件类的格式很完美,没有问题,甚至由于担心是中文引起的问题,我还把“念两句诗”改成“Sing two poems”,问题依旧。
总之一时半会无法排查出问题所在,还是赶紧解决APK的构建问题,现在因为构建失败的原因,旁边已经有一票同事正在摩拳擦掌准备把我狠狠的批判一番。所以我打算先去掉APT功能,不通过自动生成注册类的方式,而是通过手动代码注册的方式让路由工作,就当我以为事情告一段落的时候,我才发现我还是“too young”啊,构建机给了同样的错误反馈。
…………
……
…
这TM就尴尬了啊,我现在导致构建失败的提交与上一次正常构建的提交之间的差异就是给PeomAction
加多了注解而已啊,而且这个注解现在都没有用到了,难道是注解本身的存在就会导致构建失败?
突然我想起来,注解类本身我是没有加入混淆的,因为代码里没有用反射的反射获取注解,而且我设计注解类本身的目的也只是为了帮我自动生成注册类而已,这些类是编译时生成的,所以不会受到混淆功能的影响。抱着死马当活马医的心态,我把注解里面的desc
字段去掉了,万万没想到构建问题居然就解决了,而且就算我开启APT功能,问题还是没有重现,这…… 这与构建出问题的状态的差别只有一段注释的差别啊,没问题的代码看起来是这样。
1 | public static class AutoGeneratedRouteTable { |
这难道是真实存在的某种膜法在干扰我的构建过程?突然我又想起来,因为注解类本身不需要写什么代码,所以我创建Route.java
这个类后基本就没有对它进行过编辑了,我甚至已经忘了我对它写过什么代码,所以我决定看看是不是我写错了些什么。
1 | (ElementType.TYPE) |
这个注解类看起来再普通不过,一般写完之后也不需要再怎么修改了,而且这个类我是直接参(co)考(py)另外一个优秀的Java APT项目 DeepLinkDispatch 的,想必也不会有什么大坑。目前看起来唯一有更改可能性的地方就是Target
和Retention
这两个属性,至于这俩的作用不属于此文章的范畴,不做展开。
首先,我试着把**Retention
的级别由原来的CLASS
改成SOURCE
级别,没想到就这么一个小改动,编译居然通过了!如果不修改Retention
的级别,把注解里的desc
字段移除,只保留一个value
字段,问题也能解决,真是神奇啊**,顿时我好像感受到了一股来自古老东方的神秘力量。
在我一直以来的认知里,RetentionPolicy.SOURCE
是源码级别的注解,比如@Override
、@WorkerThread
、@VisibleForTest
等这些注解类,这类的注解一般是配合IDE工作的,不会给代码造成任何实际影响,IDE会获取这些注解,并向你提示哪些代码可能有问题,在编译阶段这类注解加与不加没有任何实际的影响。看一下源码的解释吧。
1 | public enum RetentionPolicy { |
原来如此,RetentionPolicy.CLASS
级别的注解会被保留到.class
文件里,所以混淆的时候,注解类也会参与混淆,大概是混淆的时候出的问题吧。总之,先看看注解类Route.java
被混淆后变成什么样子,查看 build/output/release/mapping.txt
文件。
1 | ... |
果然不出我所料,ProGuard工具在混淆注解类类**Route.java
的时候,把它的两个字段都混淆成a
了**(按道理应该是一个a和一个b,不知道是不是ProGuard的BUG,还是Route与其他库冲突了)。
所以,最后的解决方案就是把Retention
的级别由原来的CLASS
降级成SOURCE
,或者把注解类的字段改成一个。顺便一说,现在大多的Java APT项目用的还是CLASS
,它们之所以没有遇到类似的问题,大多是因为他们都选择把整个注解类都KEEP住,不进行混淆了。
通过这个事件我也发现了不少问题。其一,无论单元测试写得再完美,集成进项目之前还是有必要进行一次Release构建,以确保避免一些平时开发的时候容易忽略的问题,不然小心自己打自己的脸。以下是一次打脸现场。
所以我决定,给项目的构建机加上一次 Daily Building 的功能,每天都定期构建一次,以便尽早发现问题。
其二,除了构建的问题之外,年轻人果然还是要多多学习,提高一下自己的知识水平。设想,如果我的Java基础够扎实的话,也就不会像这次一样,犯下RetentionPolicy
错用这样低级的错误。如果有仔细阅读过 transformClassesWithMultidexlistForRelease
任务以及ProGuard工具的的源码的话,也许能很快定位到问题发生的根本原因,从而釜底抽薪一举解决问题,不像这次一样,阻塞一大半天开发进度。
以下放出这次定位问题的大致过程。
① 先定位 transformClassesWithMultidexlistForRelease
任务的源码。通过任务名字,可以很快地定位到 MultiDexTransform.java
这个类里面来,以下是这个类在执行任务时候做的工作。
1 |
|
可以看出,MultiDexTransform的主要工作是在shrinkWithProguard
和computeList
两个方法里面完成的。其中shrinkWithProguard
的工作可以定位到ProGuard工具的ProGuard#execute
方法里面。
1 | public void execute() throws IOException |
可以定位到ProGuard最后执行的dump()
方法里面,该方法生成了一个dump.txt
文件,里面用文本的形式,记录了整个项目用到的所有类(混淆后的)的文件结构。查看任务的LOG信息以及dump.txt
文件的内容,发现所有内容都正常生成,因此可以初步确定问题不是由于shrinkWithProguard
引起的。
接着看看computeList
方法,这个方法可以定位到以下代码。
1 | public Set<String> createMainDexList( |
从源码可以看出,这里调用了Android SDK里面的dx.jar
工具,入口类是 com.android.multidex.ClassReferenceListBuilder
,并传入了两个参数,分别是jarOfRoots
文件和allClassesJarFile
文件。
② 定位到dx.jar
工具里具体出问题的地方,通过上面的分析以及构建失败输出的LOG,可以看到Gradle插件调用了dx.jar
并传入了build/intermediates/multi-dex/release/componentClasses.jar
和build/intermediates/transforms/proguard/release/jars/3/1f/main.jar
两个文件。直接调用该命令试试。
1 | Exception in thread "main" com.android.dx.cf.iface.ParseException: name already added: string{"a"} |
从异常的堆栈可以直接看出,dx工具在执行AnnotationParser#parseAnnotation
方法的时候出错了,原因是有两个相同的字段a
,这也刚好印证了上面mapping.txt
文件里面的错误信息。
③ 最后定位到源码里具体出问题的地方,查看dx工具里的com.android.dx.rop.annotation.Annotation.java
的源码。
1 | private final TreeMap<CstString, NameValuePair> elements; |
到此,从成功定位到产生异常的具体地方。
④ 此外,从:app:assembleRelease --debug --stacktrace
的异常堆栈里是无法直接看出具体出异常的地方的错误信息的,不过可以通过:app:assembleRelease --full-stacktrace
命令输出更多的错误堆栈,从而直观地看出一些猫腻来。
1 | Caused by: com.android.ide.common.process.ProcessException: Error while executing java process with main class com.android.multidex.ClassReferenceListBuilder with arguments {build/intermediates/multi-dex/release/componentClasses.jar build/intermediates/transforms/proguard/release/jars/3/1f/main.jar} |
从上面的堆栈信息可以直接看出Gradle插件在调用dx工具的时候出现异常了(Process的返回值不是0,也就是Java程序里面调用了System.exit(0)之外的结束方法),对应的类为ClassReferenceListBuilder
。
1 | public static void main(String[] args) { |
由其中的 MainDexListBuilder builder = new MainDexListBuilder(keepAnnotated, args[argIndex], args[argIndex + 1])
也能进一步定位到上面的 com.android.dx.rop.annotation.Annotation.java
出问题的地方。
推荐阅读 ProGuard在插件化里的应用。
]]>1 | // +---------------------------------------------+ |
Comment Formatter is an IntelliJ IDEA plugin (also works in Android Studio) that formats comments as above. It will force all the comments to align to the longest one.
Getting Started
CommentFormatter
from release or IntelliJ Plugin Repository.Tool - Format comment
or toggle Ctrl + Cmd + L
to format.MultiDex的使用虽然很简单便捷,但是有个比较蛋疼的问题,就是在App第一次冷启动的时候会产生明显的卡顿现象。经过测试和统计,根据Apk包的大小、Android系统版本的不同,这个卡顿时间一般是2000到5000毫秒左右,极端的情况下甚至可以到20000+毫秒。通过之前的分析,我们知道具体的卡顿产生在MultiDex解压、优化dex这两个过程,而且只在第一次冷启动的时候才会触发这两个过程。那么优化的方式也很简单,在安装Apk前先对新版本的Apk做好解压和优化工作,就能在安装后第一次冷启动的时候避开这两个耗时的过程了。
在之前的章节里面讲到,MultiDex在第一次做完解压和优化dex之后,会保留当前Apk的一些信息,下一次启动时候后读取这些配置信息再判断是否需要重新解压和优化dex文件。
这个判断主要是在MultiDexExtractor#load(Context, ApplicationInfo, File, boolean)方法里进行。
1 | static List<File> load(Context context, ApplicationInfo applicationInfo, File dexDir, |
第一次调用这个方法的时候,forceReload为false,则不需要强制重新解压dex。然后调用了isModified
这个方法判断当前App的Apk包是否被修改过。
1 | private static boolean isModified(Context context, File archive, long currentCrc) { |
isModified
方法主要是判断当前App的Apk包的CRC值是否和上一次解压dex时记录的Apk包CRC一样(CRC值可以认为是一个稀疏的MD5算法,它的时间复杂度低很多,但是计算结果容易产生冲突),以及Apk文件的修改时间(文件的Last Modified Time)是否一致。如果这两项都一致的话就认为Apk文件没有产生变化(没有覆盖安装过),因此上一次解压和优化dex得到的缓存文件可以复用。
当然,光Apk包没有修改过这一项条件还不够,接下来调用了这个判断主要是在MultiDexExtractor#loadExistingExtractions(Context, File, File)。
1 | private static List<File> loadExistingExtractions(Context context, File sourceApk, File dexDir) |
这里先通过SharePreference读取上一次MultiDex保存的Apk包的dex数量totalDexNumber,然后挨个加载预定的文件路径上的dex文件,加载文件的的同时还通过verifyZipFile
方法判断dex文件的合法性。如果这个过程出现异常就认为获取上一次缓存的dex文件失败,需要重新解压。
1 | static boolean verifyZipFile(File file) { |
verifyZipFile
这个方法非常简单,解压dex文件的时候,解压出来的文件被保存成Zip包,这个方法这是检查缓存的dex文件是否是Zip包。感觉不靠谱,虽然检查MD5值比较耗时不适合这种情景,不过好歹也像检查Apk包的CRC值和修改时间一样,检查dex缓存文件的CRC和修改时间啊。不过读取SharePreference配置是一个IO操作,如果保存的数值太多的话,也是有增加耗时和IO异常的风险的。
到这里我们的方案就清晰了:
注:
这个环节必须在升级Apk前,由旧版本的Apk进行,也就是要求App拥有自主更新的逻辑。
从旧版的Apk覆盖安装新的Apk后,第一次运行App时MultiDex主要的耗时过程。这时需要把在旧版本Apk预安装得到的dex缓存文件移动到MultiDex使用的存储路径上。
原有的MultiDex,dex文件时同步从Apk包里解压出来的,所以不存在dex文件和Apk版本对不上的问题。而PreMultiDex的方案的一个问题ui是,解压dex文件和使用dex文件这两个过程是分开的,无论版本控制做得再精确,理论上也存在版本出错的问题(比如从A版本解压得到了dex文件,而用户却选择覆盖安装了B版本,这时候由于代码逻辑的不严谨导致B版本的Apk使用了A版本解压出来的dex文件)。如果想要确保dex文件的正确性,需要对Apk包里面的dex文件和解压出来的dex文件做一下MD5值校验,而这个过程比较耗时,不适合在App启动的时候做,不然PreMultiDex就失去了意义。因此,需要在第一次运行新Apk后,启动dex的校验工作,在Worker线程对dex进行校验,如果校验失败则清除dex缓存,强制让App在下一次启动的时候再执行一遍MultiDex。
在MultiDex校验失败后,需要清空MultiDex的缓存文件,禁用PreMultiDex功能,并且强制让App在下一次启动的时候再执行一遍MultiDex。
dex文件是Android虚拟机使用的可执行文件(从Java类编译得到),相当于JVM虚拟机用的class文件。但是与class文件不同,Android系统并不能直接使用dex文件,需要先使用dexopt工具对dex文件进行一次优化工作(Optimize),优化得到的odex文件才能被虚拟机加载。不同的Android设备需要不同格式的odex文件,所以这个过程只能在Android设备上进行,而不能在构建Apk的时候就处理好。
dex文件在Apk包里的文件后缀名是.dex,MultiDex从Apk包里解压出dex文件后会压缩成Zip包,文件后缀名是.zip。对dex文件进行dexopt操作后,会生成相同文件名的odex文件,后缀名是.dex,odex文件会比dex文件大许多,不要搞混这些文件。
至于为什么MultiDex解压dex文件时会进行压缩工作,可能是因为压缩后的压缩包会占用比较小的内部存储空间,因为MultiDex本来就是给旧版本的Android系统使用,一些早期的Android设备拥有的内部存储空间非常有限,而这些dex文件对于App的运行时必须的,所以才需要尽量压缩dex的体积。压缩过程会有明显的耗时,经过测试,如果不进行压缩,直接从Apk里解压dex文件,则MultiDex过程会有大约1/3的加速效果。
MultiDex其实并没有刻意保留dexopt后的缓存,如果只保留dex文件,而不保留odex文件,那么下一次启动执行MultiDex的时候,不需要重新解压dex文件,但是依然需要dexopt并产生odex文件,这个过程大概会占用MultiDex总耗时的一般左右。如果odex文件存在,但是已经损坏了,或者是一个非法的odex文件,依然会触发dexopt工作。也就是说,加载dex文件并创建DexFile对象的时候,Android系统会判断odex的缓存,以及缓存文件是否正确,具体过程在dalvik_system_DexFile.cpp里实现,有兴趣的同学可以找找dex文件结构分析的文章,这里就不挖坑了。
其实,如果dex文件和Apk的版本对不上的话,一般在启动App的时候就会出现ClassNotFound异常而导致App崩溃,接着再次启动由于没有重新MultiDex也会继续崩溃。而崩溃的时候,可能App崩溃上报系统还没来得及初始化,所以没有办法发现崩溃的问题。
为了防止这种问题,可以开发一个恢复模式或者安全模式的功能,当App出现连续的崩溃的时候,会进入恢复模式的状态,清空一些可能导致异常的数据(比如PreMultiDex的缓存),这样就能避免App因为连续崩溃而不能使用。至于怎么实现恢复,这已经是另一个领域的功能了,这里不再展开。
参考链接: Google Multidex
MultiDex 是Google 为了解决 “65535方法数超标” 以及 “INSTALL_FAILED_DEXOPT” 问题而开发的一个Support库,具体如何使用MultiDex现在市面已经有一大堆教程(可以参考给 App 启用 MultiDex 功能),这里不再赘述。这篇日志主要是配合源码分析MultiDex的工作原理,以及提供一些MultiDex优化的方案。
等等,这个章节讲的不是MultiDex吗,怎么变成Dex了?没错哈,没有Dex,哪来的MultiDex。在Android中,对Dex文件操作对应的类叫做DexFile。在CLASSLOADER 的工作机制中,我们说到:
对于 Java 程序来说,编写程序就是编写类,运行程序也就是运行类(编译得到的class文件),其中起到关键作用的就是类加载器 ClassLoader。
Android程序的每一个Class都是由ClassLoader#loadClass方法加载进内存的,更准确来说,一个ClassLoader实例会有一个或者多个DexFile实例,调用了ClassLoader#loadClass之后,ClassLoader会通过类名,在自己的DexFile数组里面查找有没有那个DexFile对象里面存在这个类,如果都没有就抛ClassNotFound异常。ClassLoader通过调用DexFile的一个叫defineClass的Native方法去加载指定的类,这点与JVM略有不同,后者是直接调用ClassLoader#defineCLass方法,反正最后实际加载类的方法都叫defineClass就没错了🌝。
首先来看看造DexFile对象的构方法。
1 | public final class DexFile { |
通过以前分析过的源码,我们知道ClassLoader主要是通过DexFile.loadDex这个静态方法来创建它需要的DexFile实例的,这里创建DexFile的时候,保存了Dex文件的文件路径mFileName,同时调用了openDexFile的Native方法打开Dex文件并返回了一个mCookie的整型变量(我不知道这个干啥用的,我猜它是一个C++用的资源句柄,用于Native层访问具体的Dex文件)。在Native层的openDexFile方法里,主要做了检查当前创建来的Dex文件是否是有效的Dex文件,还是是一个带有Dex文件的压缩包,还是一个无效的Dex文件。
加载类的时候,ClassLoader又是通过DexFile#loadClass这个方法来完成的,这个方法里调用了defineClass这个Native方法,看来DexFile才是加载Class的具体API,加载Dex文件和加载具体Class都是通过Native方法完成,ClassLoader有点名不副实啊。
当一个Dex文件太肥的时候(方法数目太多、文件太大),在打包Apk文件的时候就会出问题,就算打包的时候不出问题,在Android 5.0以下设备上安装或运行Apk也会出问题(具体原因可以参考给 App 启用 MultiDex 功能)。既然一个Dex文件不行的话,那就把这个硕大的Dex文件拆分成若干个小的Dex文件,刚好一个ClassLoader可以有多个DexFile,这就是MultiDex的基本设计思路。
MultiDex的工作流程具体分为两个部分,一个部分是打包构建Apk的时候,将Dex文件拆分成若干个小的Dex文件,这个Android Studio已经帮我们做了(设置 “multiDexEnabled true”),另一部分就是在启动Apk的时候,同时加载多个Dex文件(具体是加载Dex文件优化后的Odex文件,不过文件名还是.dex),这一部分工作从Android 5.0开始系统已经帮我们做了,但是在Android 5.0以前还是需要通过MultiDex Support库来支持(MultiDex.install(Context))。
所以我们需要关心的是第二部分,这个过程的简单示意流程图如下。
(图中红色部分为耗时比较大的地方)
现在官方已经部署的MultiDex Support版本是com.android.support:multidex:1.0.1
,但是现在仓库的master分支已经有了许多新的提交(其中最明显的区别是加入了FileLock来控制多进程同步问题),所以这里分析的源码都是最新的master分支上的。
MultiDex Support的入口是MultiDex.install(Context)
,先从这里入手吧。(这次我把具体的分析都写在代码的注释了,这样看是不是更简洁明了些?)
1 | public static void install(Context context) { |
具体代码的分析已经在上面代码的注释里给出了,从这里我们也可以看出,整个MultiDex.install(Context)的过程中,关键的步骤就是MultiDexExtractor#load
方法和MultiDex#installSecondaryDexes
方法。
(这部分是题外话)其中有个MultiDex#clearOldDexDir(Context)方法,这个方法的作用是删除/data/data//files/code-cache,一开始我以为这个方法是删除上一次执行MultiDex后的缓存文件,不过这明显不对,不可能每次MultiDex都重新解压dex文件一边,这样每次启动会很耗时,只有第一次冷启动的时候才需要解压dex文件。后来我又想是不是以前旧版的MultiDex曾经把缓存文件放在这个目录里,现在新版本只是清除以前旧版的遗留文件?但是我找遍了整个MultiDex Repo的提交也没有见过类似的旧版本代码。后面我仔细看MultiDex#getDexDir这个方法才发现,原来MultiDex在获取dex缓存目录是,会优先获取/data/data//code-cache作为缓存目录,如果获取失败,则使用/data/data//files/code-cache目录,而后者的缓存文件会在每次App重新启动的时候被清除。感觉MultiDex获取缓存目录的逻辑不是很严谨,而获取缓存目录失败也是MultiDex工作工程中少数有重试机制的地方,看来MultiDex真的是一个临时的兼容方案,Google也许并不打算认真处理这些历史的黑锅。
接下来再看看MultiDexExtractor#load这个方法。
1 | static List<File> load(Context context, ApplicationInfo applicationInfo, File dexDir, |
这个过程主要是获取可以安装的dex文件列表,可以是上次解压出来的缓存文件,也可以是重新从Apk包里面提取出来的。需要注意的时,如果是重新解压,这里会有明显的耗时,而且解压出来的dex文件,会被压缩成.zip压缩包,压缩的过程也会有明显的耗时(这里压缩dex文件可能是问了节省空间)。
如果dex文件是重新解压出来的,则会保存dex文件的信息,包括解压的apk文件的crc值、修改时间以及dex文件的数目,以便下一次启动直接使用已经解压过的dex缓存文件,而不是每一次都重新解压。
需要特别提到的是,里面的FileLock是最新的master分支里面新加进去的功能,现在最新的1.0.1
版本里面是没有的。
无论是通过使用缓存的dex文件,还是重新从apk中解压dex文件,获取dex文件列表后,下一步就是安装(或者说加载)这些dex文件了。最后的工作在MultiDex#installSecondaryDexes
这个方法里面。
1 | private static void installSecondaryDexes(ClassLoader loader, File dexDir, List<File> files) |
因为在不同的SDK版本上,ClassLoader(更准确来说是DexClassLoader)加载dex文件的方式有所不同,所以这里做了V4/V14/V19的兼容(Magic Code)。
Build.VERSION.SDK_INT < 14
1 | /** |
14 <= Build.VERSION.SDK_INT < 19
1 | /** |
从API14开始,DexClassLoader会使用一个DexpDexPathList类来封装DexFile数组。
1 | final class DexPathList { |
通过调用DexPathList#makeDexElements方法,可以加载我们上面解压得到的dex文件,从代码也可以看出,DexPathList#makeDexElements其实也是通过调用DexFile#loadDex来加载dex文件并创建DexFile对象的。V14中,通过反射调用DexPathList#makeDexElements方法加载我们需要的dex文件,在把加载得到的数组扩展到ClassLoader实例的”pathList”字段,从而完成dex文件的安装。
从DexPathList的代码中我们也可以看出,ClassLoader是支持直接加载.dex/.zip/.jar/.apk的dex文件包的(我记得以前在哪篇日志中好像提到过类似的问题…)。
19 <= Build.VERSION.SDK_INT
1 | /** |
V19与V14差别不大,只不过DexPathList#makeDexElements方法多了一个ArrayList参数,如果在执行DexPathList#makeDexElements方法的过程中出现异常,后面使用反射的方式把这些异常记录进DexPathList的dexElementsSuppressedExceptions字段里面。
无论是V4/V14还是V19,在创建DexFile对象的时候,都需要通过DexFile的Native方法openDexFile来打开dex文件,其具体细节暂不讨论(涉及到dex的文件结构,很烦,有兴趣请阅读dalvik_system_DexFile.cpp),这个过程的主要目的是给当前的dex文件做Optimize优化处理并生成相同文件名的odex文件,App实际加载类的时候,都是通过odex文件进行的。因为每个设备对odex格式的要求都不一样,所以这个优化的操作只能放在安装Apk的时候处理,主dex的优化我们已经在安装apk的时候搞定了,其余的dex就是在MultiDex#installSecondaryDexes
里面优化的,而后者也是MultiDex过程中,另外一个耗时比较多的操作。(在MultiDex中,提取出来的dex文件被压缩成.zip文件,又优化后的odex文件则被保存为.dex文件。)
到这里,MultiDex的工作流程就结束了。怎么样,是不是觉得和以前谈到动态加载技术(插件化)的时候说的很像?没错,谁叫它们的核心都是dex文件呢。Java老师第一节课就说“类就是编程”,搞定类你就能搞定整个世界啊!
MultiDex有个比较蛋疼的问题,就是会产生明显的卡顿现象,通过上面的分析,我们知道具体的卡顿产生在解压dex文件以及优化dex两个步骤。不过好在,在Application#attachBaseContext(Context)中,UI线程的阻塞是不会引发ANR的,只不过这段长时间的卡顿(白屏)还是会影响用户体验。
目前,优化方案能想到的有两种。
大致思路是,在安装一个新的apk的时候,先在Worker线程里做好MultiDex的解压和Optimize工作,安装apk并启动后,直接使用之前Optimize产生的odex文件,这样就可以避免第一次启动时候的Optimize工作。
安装dex的时候,核心是创建DexFile对象并使用其Native方法对dex文件进行opt处理,同时生产一个与dex文件(.zip)同名的已经opt过的dex文件(.dex)。如果安装dex的时候,这个opt过的dex文件已经存在,则跳过这个过程,这会节省许多耗时。所以优化的思路就是,下载Apk完成的时候,预先解压dex文件,并预先触发安装dex文件以生产opt过的dex文件。这样覆盖安装Apk并启动的时候,如果MultiDex能命中解压好的dex和odex文件,则能避开耗时最大的两个操作。
不过这个方案的缺点也是明显的,第一次安装的apk没有作用,而且事先需要使用内置的apk更新功能把新版本的apk文件下载下来后,才能做PreMultiDex工作。
这种方案也是目前比较流行的Dex手动分包方案,启动App的时候,先显示一个简单的Splash闪屏界面,然后启动Worker线程执行MultiDex#install(Context)工作,就可以避免UI线程阻塞。不过要确保启动以及启动MultiDex#install(Context)所需要的类都在主dex里面(手动分包),而且需要处理好进程同步问题。
参考资料:
]]>LOG 是任何一种编程语言的第一个API,通常被初学者用来打印 Hello, World!。
有研究显示,不使用 LOG 或者使用姿势错误的人,感情路都走得很辛苦,有七成的比例会在 34 岁的时候跟自己不爱的人结婚,而其余三成的人最后只能把遗产留给自己的猫。
毕竟爱情需要书写,不能是一整张白纸。
LogCat是Android开发者们最熟悉不过的日志打印工具,几乎每一个Android项目里面都包含着大量的Log相关代码。不过,或许是因为Log实在是太过于普通,所以许多人在使用它的时候就显得非常随意,这些错误的使用姿势却会在不经意间给我们带来不少的大坑。
许多同学喜欢在开发阶段用Log输出当前的一些环境数据,用于调试代码,但是在调试完成后却忘了关闭这些Log,导致发版出去的应用里面还会继续输出这些LOG,这样不仅会造成不必要的性能丢失,也会暴露一些敏感的数据,这些都是我们不愿看到的。
首先,我们要给Log进行分级,规定“DEBUG版本输出哪一些级别的LOG并屏蔽哪一些级别的LOG,而RELEASE版本又输出另一些级别的LOG并屏蔽另一些级别的LOG”,这样在开发阶段能够输出我们调试需要的LOG,而同时又能保证放送的版本能够屏蔽这些敏感的LOG。但是在开发阶段我们不应该特意去注意这些细节,所以必须开发一个Log工具库,在框架层级解决这个需求。
同时,需要注意的是,用于作为“开启/关闭Log”的开关必须是一个常量,而不能是一个变量(使用常量的话,在编译代码的时候,如果常量为false),编译器会直接把调试部分的Log代码直接去掉,而使用变量作为开关的话,这个判断逻辑会继续保留,一方面会造成性能丢失,另一方面在运行时也可以通过Hack手段强行开启这部分Log代码。
另外,“开启/关闭Log”的开关必须写在Log方法外部,也就是说必须先判断“开启/关闭Log”条件,再调用Log方法,因为在调用Log方法的时候已经造成了性能丢失,而且调用方法的时候,会先构造好改方法需要的参数(按照参数顺序从右往左),再调用方法,而许多人喜欢在调用Log方法的时候计算需要打印出来的内容,这里是最容易造成性能丢失的地方。因此,如果为了图方便,写一个Log工具类,在工具类内部去判断是否应该开启或关闭Log,事实上已经造成了不少的性能丢失。正确的使用姿势应该是:
1 | public static final boolean DEBUG = true; |
尽管Log造成的性能损失很小,但是如果在循环体内部循环调用Log方法的话,那总体的丢失的非常可观了,所以不应该在循环体内部使用Log,正确的做法是在循环体内部拼接需要打印的内容,等跳出循环体再一次打印出来。
除了常见的循环体外,还要一个需要注意的场景就是Adapter。ListView/RecyclerView是Android开发中最常用的控件,因此Adapter使用的情景也很多。滚动屏幕的时候,ListView/RecyclerView会在通过Adapter频繁地绑定ItemView和数据,而且这些都是在UI线程里进行的,所以如果在绑定的过程中调用Log,可能会造成明显的卡顿。
至于Log到底会丢失多少性能,一般情况下,Log的性能丢失很小,毕竟是这么常见的系统Api,肯定是身经百战,早就是“best performance”了。不过我曾经有个RecyclerView在MIUI上非常卡,一开始我是RecyclerView布局没优化好,最终定位到Adapter内部的一处Log上,卡顿的地方出现在Log的Native实现。MIUI 到底对用户输出的日志做了什么处理呢?非常神奇。
在调试代码的时候,我们经常通过LOG来定位Bug。同理,当线上的版本出现问题的时候,我们也希望能通过LOG来定位问题所在。但是问题是用户的设备上的打印出来的LOG我们根本没有方法获取,唯一的手段就是当用户设备出现问题的时候,把设备借过来连上IDE用LogCat查看输出的LOG……显然这是不可行的。
这种时候,我们可以在打印重要LOG(比如重要路径的触发点、或者一些异常类的信息)的时候,一并把这些信息记录到文件里。在用户反馈系统里面,一并将这些文件上传到我们的用户反馈服务器,这样在处理反馈问题的时候,就能拿到重要的参考日志了。
BLog 是 Android SDK 的 LOG 工具 {@link android.util.Log} 的加强版,以方便在开发时用来 操作调试日志。
注意,尽管BLog支持关闭Log的输出,但是在你调用 BLog.v(String)
的时候,其实已经造成了性能 丢失,所以请尽量使用正确的姿势来使用BLog,比如
1 | if (BuildConfig.DEBUG) { |
https://github.com/kaedea/b-log
补充 2016-11-20 因为现在公司项目上也采用了BLog这个工具,开源公司项目相关的代码需要安装既定的流程来,所以暂时不公开源码。有兴趣的同学可以试下联系我交♂流。
高中开始折腾电脑DIY,当然硬件玩不起,只是折腾系统,一开始折腾重装Windows系统,玩腻了就折腾Linux,到后来开始打算折腾Mac。在普通PC上安装苹果的OSX系统(现在叫MacOS)的行为叫做黑苹果(Mackintosh),相反的,苹果自家产品自带的系统叫做白苹果(Mackintosh)。不过安装黑苹果比起Windows和Linux实在是难多了,所以那时候看教程看得一脸懵逼就结束了。
最近在公司开始正式切换到Mac系统上进行开发工作,但是回到家里就得用WIN10(Fujitsu LH532)进行开发,有个非常头疼的问题,就是Mac上的快捷键和Windows大相径庭,所以又产生了安装黑苹果的想法。看了一周的攻略之后,我迫不及待地开始了。
首先需要学习一些安装黑苹果中需要用到的基本姿势,目前主要有以下渠道。
http://www.tonymacx86.com/
这个可以说是黑苹果的鼻祖,最前沿的阵地,在这里你可以学习到最新的黑苹果调教姿势,也可以在这里查询哪些PC硬件适合安装黑苹果,自己的电脑遇到兼容性问题也可以在这里搜索是不是有别人遇到类似的问题。唯一的要求,就是你的英文要够好。
PCbeta 黑苹果乐园
PCbeta 是国内PC电脑DIY强迫患者的集聚地,在这里你也可以找到不少详细的黑苹果安装攻略。
在我学习黑苹果基础姿势的时候,最头疼的问题,就是这些攻略教程实在是太多太杂了,而且质量参差不齐,杂乱无序,看来看去,许多术语都没有解释,莫名其妙,所以我才花了一个星期的时间才初步看明白了。
假设你已经知道了怎么安装Windows,那你应该知道安装系统无非就是两个步骤:
当然现在的Windows系统相对容易安装许多,它自带了许多最新的驱动,所以对于常见的PC硬件来说,有可能都不需要自己额外再安装驱动。而且,Windows有许多GHOST版本的安装镜像,里面事先打好了各种驱动和常见的软件、补丁程序,所以有时候你都不用进行第二步。
对于安装黑苹果系统来说步骤也差不多,只不过,Mac系统对硬盘格式有要求,所以首先你可能要处理自己的硬盘的格式问题。其次,因为Mac系统是不被允许安装在苹果之外的产品上的,所以不能直接引导Mac系统安装程序,需要额外的软件来辅助引导。最后,MacOS自带的驱动程序许多情况下都不能兼容一般的PC硬件,所以安装完系统后,还要进行一系列的安装驱动的工作。
简单来说,安装黑苹果需要经过以下步骤。
现在的硬盘主要有MBR和GPT格式,MacOS只能安装到GPT格式上,如果需要安装到MBR格式的硬盘上,需要在安装镜像上打上额外的补丁程序。当然现在有许多别人打好补丁的系统安装镜像,这种镜像叫做“懒人版”安装镜像。具体怎么区分硬盘格式和打补丁就不讨论了,请善用搜索工具。
我的LH532的硬盘是三星的SSD,是MBR格式的,为了图方便使用了懒人版。
搞好了系统安装镜像之后,并不能直接启动安装,需要制作一个用于引导的启动盘,以前启动盘的媒介大多是光驱启动,现在一般制作成U盘启动,也可以是硬盘划一个分区出来启动。制作启动盘的过程在Windows上和在MAC略有差别(后者相对简单),但是基本过程都是一样的,就是将系统安装镜像烧录到指定的媒介上。
以前安装多系统的时候也需要引导辅助工具,比如EasyBCD,可以方便地引导到Windows或者Linux系统。引导问题也是黑苹果最核心的问题,现在的引导工具有Clover(四叶草),一般用来引导安装到GPT格式的硬盘上。另外有Chameleon(变色龙),用来引导安装到MBR格式的硬盘上。
把相应的引导辅助工具制作成U盘启动,就可以启动引导辅助工具来引导MacOS的系统镜像了。注意,这里并不像安装Windows那样直接引导系统镜像并运行安装程序就完事,除了使用相应的引导辅助工具之外,还需要根据自己电脑的配置,在引导辅助程序里面设置好相应的配置文件(Clover参考config.plist,变色龙参考Boot.plist),这也是安装黑苹果的关键步骤之一,能不能正常完成系统的安装工作就这一步,毕竟只要能成功进入系统,其他驱动问题都可以在系统内解决,如果连安装工作都无法完成,就没法进行下一步工作了。推荐搜索和自己配置(特别是主板)一致的设置方式。
这里也有另一种观点,传统使用光盘或者U盘引导安装Windows系统,都是利用Bios自带的引导功能,但是Bios的引导功能很有限,不足以引导安装Mac。所以可以把EsayBCD、Chameleon、Clover等引导工具看成一个微型的操作系统,电脑启动的时候Bios只负责引导到这些微型操作系统,之后“引导到Mac系统、双系统甚至多系统”等复杂工作在微型操作系统里面完成。
很明显,我的LH532用的是变色龙引导,plist也是在网上找到别人的LH532的调教方式。
如果上一步完成了,应该就能成功启动系统安装程序并安装系统了。接下来就是安装驱动程序了,到了这一步就因人而异了,如果有些硬件不能正常工作(比如常见网卡、声卡和显卡,俗称“三卡”问题),就搜索改硬件对应型号的驱动吧,现在主流的配置一般都有别人提供的驱动。
折腾了一天,我的笔记本的黑苹果总算搞定了,安装了WIN10 + El Capitan双系统。怎么说呢,黑苹果用起来比Windows还稳定。经过几年的的“折磨”后,我的LH532用WIN10时不时总会蓝屏,但是用El Capitan却一直很稳定,当然还是有一些问题的。
好吧,感觉LH532能这样续命已经超出我的期待了,性能跑分居然还能顶上中配的MBP13。虽然安装了双系统,但是用上El Capitan之后,我就很少用回WIN10了。
安装过程中用到的工具和驱动打包:Hackintosh/Feya_LH532
参考链接:OS X EI Capitan 10.11 GM1 黑苹果懒人版变色龙引导安装教程