乐趣区

140M到67M学而思网校如何在一周内构建一套可持续的瘦身系统

APP 为什么要减包?

APP 体积越大推广转化成本越高,因为平台功能众多,学而思网校的 APP 体积是在 144m 左右,疫情期间由于公益直播涌入大量用户,转换率上的硬伤更加暴露出来。同时移动部设定了自我突破的若干指标,转换率是关键指标,背负紧急军令我们开始了减包任务,一定要做到 70m。

为什么不用插件化?

19 年团队曾经尝试过插件化技术,经过两个项目试水碰到一系列问题,最终放弃使用插件化,原因如下:

  1. 插件技术原理是通过 Hook 或者 Reflect 技术修改系统 libs 和 framework 代码,Android 系统版本 设备 ROM 众多,Hook Reflect 很难 100% 兼容。

  2. 学而思网校平台有 20+ 的二级工程,一个工程变更重新打包时,插件资源 id 的重新分配,整体工程变更导致 20 多插件变动需要重新维护,维护人力成本有点大。

  3. 插件技术使用时存在数据传递问题 自定义 UI 显示问题,权限重复申请等问题。

  4. 插件化的核心是 ClassLoader,按照谷歌的文档,最快 Android 12 将会被限制, 未来有不确定性。

减包计划实施难度?

  1. 涉及到 20+ 的二级工程 资源类型众多 调用代码分布广泛,要求在底层框架统一实现核心技术。
  2. 需要兼容 Android4.4 到最新的版本系统,同时核心技术兼容后续系统迭代。设备上需要兼容各个手机品牌的高中低,兼容任务繁重。
  3. 产品迭代迅速,为了避免后续开发导致 APP 慢慢滋长,需要设计统一的技术框架保持持久轻量。
  4. 总体开发时间一周,测试一周,各个业务线还在并行开发,为了保障时间节点,技术框架需要做到最小的业务代码代动。

减包前 APP 体积汇总。

通过数据统计发现,20 多个工程的 res 图片资源 assets 的 lottie 动效资源 libs 下的 so 文件合计约有 70m。其他零散的 100kb 文件有 6m 左右。20 多个二级功能,其资源一次性打进 APP 里是不合理的,毕竟用户常用的就那么几个。为什么不把资源分离出来托管到云端,使用时再拉取呢?想法很简单,但是面临一系列的问题,我们有 6000 多张图片,托管 CDN 的话,业务代码都要修改访问链接不现实。一个想法在内心产生,可以做一个离线附件的技术框架嘛。

附件框架的方案

附件框架:开发时资源打进 APP 不影响业务方开发调试预览;发版时指定的资源统一分离出来托管到云端,进入对应功能前确保资源包下载完了,运行阶段不受影响。文字虽短,框架层需要支持一下特性:

  1. 资源分离需要做到脚本自动化,并且只分离指定目录的资源文件,分离出来的 zip 应该是多个,并且和 20 多个工程形成一一对应关系。

  2. 资源下载需要做到按需下载,进入哪个功能下载哪个资源,避免一次性全部下载导致的 loading 时间太长。为了减少 loading 出现,需要根据业务权重做后台预加载机制。

  3. 框架层面在保证按需下载的前提下,实现业务层面的统一拦截下载,以避免大量的业务代码修改和调试,做到业务方无感知框架。

  4. 以前资源在 APP 内,附件框架的资源在下载后,框架代码需要做到全方面的资源访问替换技术,以避免大量的业务代码变动,做到业务层面无感知。

  5. 考虑到存量用户基数大,各个业务版本迭代资源变动小,为了进一步避免或减少 loading 出现的概率时间,附件框架可以做增量更新技术。保证存量用户更新资源时,资源包体积减少 95%。

  6. 20 多个离线 zip 增量迭代 10 个版本,会产生上百个资源文件,对应的人力维护成本也大。需要配套的自动化附件包发布脚本,一是减轻负重,二是避免人为性失误。

  7. 框架需要考虑失败重试机制 需要做到多云备份预防网络事故 需要做到内置外置卡双存储避免极端情况。需要完整的日志链条以持续优化。

资源分离技术说明

  1. 首先规定了附件目录 attach, gradle 脚本会给每个二级工程生成该目录。业务方只需要把 lottie so 以及其他大文件移动到附件目录,不需要修改代码。

  2. Jekins 打 release 包时,分离脚本启用了,gradle 脚本会自动遍历二级工程:每个工程 res 下的图片文件会打到 zip,源文件会用 xml 文件占位替换,每个工程的 attach 文件会打包到 zip 中。

  3. 最终 Jekins 产生了 20+ 的 zip 文件,打包完成后命令行运行脚本,自动化发布资源文件到云端。

资源发布自动化技术

  1. 批量编译点九图 确保 APP 使用时无失真拉伸
  2. 批量使用熊猫 WEBP 技术对图片文件优化 以减少资源体积
  3. 自动对比历史版本归档记录 产生对应的增量更新文件
  4. 同时发布多个资源包到案例云和腾讯云 双云避免网络事故

使用 python 脚本自动化发布做到人力不及的流程,避免了类似于插件化维护的管理成本。

抽象统一的下载框架

  1. 底层框架统一拦截跳转,确定需要进入的二级模块,检查下载对应资源文件,下载后继续跳转。统一实现了 20+ 业务的核心代码,避免业务改动。

  2. 下载环节做到网络错误感知,阿里云腾讯云自动切换,4 次失败重试避免网络事故。文件存储时优先内置卡,次要外置卡存储,避免极端的文件读写问题。

  3. 框架层面统一文件管理,版本迭代管理,避免修改业务代码。同时增量更新确保用户最小的下载量。

资源访问的无缝替换

附件资源分离做到自动化 发布做到自动化 下载做到了抽象统一。再做到无缝替换技术,基本上业务代码变更就很微小。所谓无缝替换,就是从关键接口层面统一 APP 内置资源 下载资源的访问。核心技术一处实现,业务代码无需变更。下面列举 res 无缝替换 lottie 无缝替换 Glide 无缝替换。

如你所见,无缝替换技术是重写关键接口而非 Hook 的方式,这让网校 APP 做到 100% 兼容;从内核层面进行流替换技术,一处变更全场景生效,避免了大量的业务改动。

祛除 Unity 3D 内核的历程。

在 APP 多个业务中,互动环节要显示 3D 粒子效果的机器人,阿丘之类的动画。因为制作 3D 粒子效果的成本比较大,团队起初定的技术方案是采用 Unity 3D 渲染模型。发现 Unity 3D 本身是很出色的特别是对于游戏,但是对于我们网校 APP 这个大平台而言,却不是那么合身,原因如下:U3D 的 library bin 文件占据着 15M 的 APP 体积;U3D 是不开源的碰到一个手机崩溃无从解决;载入释放 U3D 内核内存需要 5 秒产品体验差;使用 U3D 时内存多开销 170m。这种场景让想起几年前在使用 Cocos 渲染时,为了减少 40m 的内核库,居然花费了一周时间精简编译 Cocos 的艰辛历程。这种场景代表某种尴尬:为了特效引入了一个太重的技术方式,这种技术无法做到轻量化,不大适合平台化的 APP。

偶然在使用一个录屏软件时,产生点灵感,3D 特效复杂如果设计动画帧成本太大所以设计部不接受,如果我们做个截屏小工具,运行这些特效连续截屏,截取指定区域,生成动画帧,网校 APP 直接使用程序截屏的动画帧,就可以祛除 U3D Cocos 这种重量级内核了吧,毕竟用户看的是屏幕,产品要的是实现了而不是怎么实现。抱着试一试的心态,开始编写这个工具,中途也遇到了些问题。

  1. 时间平滑问题:动画效果很重要一点就是帧之间的时间平滑度,起初的程序控制设定在 30ms 一帧采集,但是发现实际的采集结果有的是 30ms,有得是 200ms,时间平滑度出入太大效果不理想。通过时间数据采集,发现采集后编码 PNG 时间,文件 IO 时间变动,中间又有系统内存回收导致的。再次修改采集方法,采用双线程模型加高缓存策略,保证了时间平滑度在 30ms 左右。
  2. 祛除背景问题:截屏窗体采用纯白背景 0XFFFFFFFF,设想对截屏图片使用程序去除白色部分,然而发现有些色素是有 Alpha 通道的。理论上讲白色可以和任意 Alpha 通道色值混合成目标色值。这就意味着还原 Alpha 通道色值有些不现实,再次陷入困境。。。查阅了颜色混合公式 Dst = (Src * Alpha + (256 – Src.Alpha * Alpha / 255) * Dst ) / 255, 联想到对于同一帧如果分别采用白色背景和红色背景,利用混合模式对比不就能还原出色素的 Alpha 和 RGB 值嘛。于是再次修改采集程序,一个动作分别用红色背景和白色背景采集,生成两套动作。编写相似度算法分别找出每一帧的红色图和白色图,反向色素混合,果然能还原 Alpha 通道和 RGB 值~
  3. 祛除噪点问题:在祛除背景还原 Alpha 通道后,自以为没问题了,后来发现少量图片有零星噪点,深入分析代码发现,每一帧的白色帧和红色帧不是 100% 的吻合,图片边缘合起来对比还是有那么一两个像素的误差。开始各种尝试解决这种误差,祛除噪点,最终找到合适的算法,类似于卷积思想:以白色为基础帧,红色为对比帧,还原白色(X Y)的色素时,通过红色(X Y)周围 9 个点卷积还原,质量无损失,噪点完美祛除~

解决三面三个问题,Unity 3D 截取转动画实现了,每个动作帧生成时间在 4 分钟左右。后续编写独立的动画组件把内存控制在 15m 以内,成功在两个项目中实际应用。本次瘦身方案采用这个策略,祛除掉了 Unity 3D 内核减掉 15m 体积,功能依然满足,成功达成目标!本次减包的主要方案就是资源分离下发,祛除 Unity 3D,顺便删除少量冗余资源,媒体库合并等方式。

提醒:可以理解做了个工具,可以截取指定区域的画面,通过算法生成了设计级别的动效,这种方式可以应用在多个场景,比如 cocos 等其他特效技术替换。

我们遇到过哪些困难?

踩坑一:怎么分离 drawable/image 附件

安卓最常见的图片是 drawable/image,系统调用的方式就那么几种,实现起来会相对轻松些。先从 drawable 分离着手,开发 Android 的小伙伴都知道,gradle 在编译时会把 drawable/images 存放在 build 目录下。起初想添加一个脚本,编译时把这些 drawable/images 图片替换成占位小文件。经过两天的重复试验,虽然脚本替换成了小占位文件,但是 APP 编译失不通过了,没办法只能去查阅 Gradle 编译流程,发现一旦 Gradle 完成编译前准备,随意更改 build 是不行的,其中编译环节过多不再赘述。编译中替换不行,那就换成编译前替换试试看。修复脚本,以工程为单位,识别 sourceSet.res,把 sourceSet.res copy 出一份新的目录,命名为 dir。替换 dir 中所有的 drawable/images 为占位文件,编译前动态重置 sourceSet.res = dir 成功了。经过两天多的探索,初步找到图片分离占位的脚本方式,开头还算可以~

踩坑二:怎么无缝替换 drawable/image

这个技术是最关键的环节,只有做到无缝才能确保不需要变更各业务代码,从底层确保质量。按照起初设想,进入某个功能前下发本模块的 zip 文件并解压,显示 drawable 时无缝替换掉,实际显示占位文件描述的真实图片。为实现无缝替换技术,浏览 Android Framework 的系统源码,发现可以使用 Drawable Tag 扩展,扩展 ReplaceDrawable 新类,在 xml 文件定义 <com.parentsmettins.drawable.ReplaceDrawable file=“project/imagePath”/>,系统内核会反射 package 包下的 ReplaceDrawable 实例,可以在实例化载入真实图片显示,运行起来还不错,不用修改业务代码,就能无缝替换显示。忍不住爽了下,赶紧在云平台选择不同的设备和系统测试兼容性,几台手机崩溃了。失落之余发现,这些手机普遍在 6.0 以下系统,开始漫长的下载 Android 各种版本的 FrameWork 源码做对比, 最后确认:Drawable Tag 扩展特性在 6.0 以前的系统版本是不支持的!想到判定属于 6.0 以下的系统,Hook Resouces 类 Cache 的 get 方法扩展支持 Drawable Tag,又开始漫长的 Resouces Hook 测试验证工作,终于算支持 6.0 以下的系统了,随后在两个独立模块中测试无缝替换显示技术,妥妥的。然而应用到第三个工程测试,APP 奔溃了。。。追踪下去发现有个混合 drawable 载入 ReplceDrawable Tag 时报错,那个业务的混合 drawable 使用到了无法 Hook 的 API,这样的 API 还有几处。困难的工作总是这么意外,暂停编码,再次浏览系统代码。结论如下:不能使用 Hook 方式兼容,因为总会有不能 Hook 的地方,实现必须遵守 Android 标准这样才能稳妥。回顾了 Framework 对于 BitmapDrawable NinePathDrawable 的所有 API,找到 标准兼容方式。就是修改占位文件内容如下,同时重写 Resources 类的流读取方法,实现方式是获取资源 id 的类型,如果是 xml 文件,判断是否有 file 属性,有就认为是占位文件,返回 file 指定的已下载文件流。这种全新的方式既遵守 Android 标准,也不需要 Hook,完美兼容各种 drawable 调用场景。因为我们的资源描述是标准的 Android API,各种版本都支持,替换是从最底层的流层面完成的,各种 API 追踪都适用。完成这个最核心的无缝替换显示技术,隐约感觉到方案是可行的!

踩坑三:怎么无缝显示 lotties/image

APP 第二大资源是丰富的 lottie 动效,动效执行环节可能要修饰渲染素材,这样的动效场景遍布各个模块并且数量巨大,不同伙伴的调用还有不少差异。打包时分离到 zip 附件中轻松实现,但是无缝替换有些困难。起初设计方式是提供一套兼容 API 给各个业务方,各个业务方修改自身代码适配。刚开始实施,各个业务方反馈修改代码太多,完成兼容 API 替换会耗费大量时间,出现 BUG 的可能性也随之提高,调用兼容 API 方式实施困难,调整技术方案做到类似 drawable/image 的无缝实现非常必要。又开始耗费时间阅读 lottie 源码,发现内核代码会根据 images 路径和 data.json 信息从 assets 中寻找素材文件,猜想可以在 lottie 内核层面重写资源寻址实现,优先从下载目录中寻址,最终技术验证通过。因为不需要修改对应功能代码,原本计划多人一周的 lottie 方案,在一天内完成了。这个细节也提醒了我们,熟悉源码思想的重要性,技术层面深入一点多想一点,整体工作量小很多。

踩坑四:为什么附件 library 执行崩溃

随着 drawable lotties 分离无缝接入成功,基本完成了编译链 发布链脚本,也可以把 so 等 library 库采用统一的流程来做呀。随后添加 library 的分离流程,载入 so 时采用 Compat 的方式从本地存储卡载入,本以为是个简单的事情,发现几乎所有的手机执行 so 程序崩溃。。。

又开始追踪各个系统 System.load(path)的源码实现,发现在高版本的系统中,Android 的权限更加严格,特别是执行权限。起初 library 下载到 /Android/data 目录下,这个目录是没有执行权限的,修改为 /data/data 目录下,该目录有执行权限,解决了这个问题。

踩坑五:怎么构造抽象统一的下载

目前学而思很多业务中有不少下载代码,下载校验,文件管理等,如果离线资源,20 多个业务都要添加下载代码,这对于精简代码非常不利,还需要测试成本确保质量。起初发现几乎所有的模块跳转都在架构组设计的 Dispatcher 类中实现,便设计在个业务的 Dispatcher 入口处拦截并下载对应功能资源。忙碌了 20 多个小时修改了这么多业务的 Dispatcher 类并检查,跑码测试,突然发现有个模块的没有资源拦截和下载,导致整个功能素材显示出问题。CR 整个代码,发现跳转除了 Dispatcher 还有少量的 Arouter Scheme 以及原生的 Start 方式,最初的想法不全面还修改了业务代码,只能回退梳理代码流。发现不管 Arouter Dispatcher Scheme 最终都调用了 Activity 的 startActivity 方法,查阅 Android 系统的 Activity 源代码确定可以用参数 Intent 的 ComponetName 来判断要跳转的模块,临时拦截跳转下载本模块资源。因为各个模块的 package 都是 prefix + businessName 方式,这为我们抽象实现 20 多个业务资源下载提供了可能。编码完毕后,测试起来还不错。然而在全功能测试流程中,又碰到了 loading 不显示,或者进入直播时直接失败,追踪下去原来是绑定下载服务失败,主要是跨进程问题还有系统差异问题,再次对比不同版本的 Service 差异,修正下载服务代码支持跨进程问题。自以为方案没啥问题,又遇到从学习中心进入模块时,没有走到拦截流程,原因是拦截代码写在 Base 类中,绝大部分的业务都继承了 Base 类,少量的业务没有继承 Base 类,为了避免人为疏漏就编写代码检查脚本,编译时检查全工程的业务 Activity 如果不是继承基类,就报错停止编译提醒业务方修改继承。有了这个脚本检查,确保了无遗漏才敢进入下一个技术环节。

踩坑六:非离线的首页素材显示问题

在我们的方案设计中特殊模块工程不分离资源,比如首页,发现,个人中心,其他独立模块是分离附件离线的。应用方案后发现首页等模块少量的素材显示有问题,只能再次开启埋坑之旅。发现出现显示的问题的素材,其名称和其他分离工程的素材重名,gradle 打包时选择了占位文件,而首页的原始图片不会编译到 APP 中。如果与首页资源重名的工程资源还没下发,框架代码找不到下载文件,会显示纯黑 或者纯蓝。因为不知道这种重名资源有多少个,又开始编写脚本统一检查,发现 156 处重名,共计 312 个素材!耗费大半天一个个修改名称避免重名,好在这些 drawable 类修改后,code 也能快速识别出来修改资源符。

踩坑七:浏览器 WebView 怎么崩溃了

在测试中意外发现,应用技术方案后,在 WebView 中长按,程序崩溃。让人陷入懵逼状态,APP 只是无缝替换显示离线资源,WebView 只是加载 URL 链接也不会使用本地资源,怎么会崩溃?事情做到这个地步只能去排查,又开始艰辛的阅读 webkit 源代码。原来长按 WebView 时,webkit 要弹出选择菜单,菜单的素材是在系统中,在载入 WebView 组件时,系统 Resouces 实例会把 webkit 的素材路径加入进来。起初我们为了做到无缝替换重写并替换 Resources 实例,重写后没有载入 webkit 素材路径导致资源找不到崩溃, 而 APP 又没法获取不同版本不同手机的 webkit 素材路径一时陷入混沌。经过多次尝试验证,我们发现不能简单重写 Resources,应该采用装饰者模式重写,这样访问 APP 资源时返回已下载文件流,访问其他资源如 webkit 素材,采用 System 原有的 Resources 实例实现,这样解决了问题。

踩坑八:Glide 为什么显示不了本地素材

熟悉 Glide 的伙伴们都知道,Glide 是图片加载显示框架,可以包括 url 图片,文件图片,APP 本地素材等。按照开始的设想,Glide 会调用 Resources 实例载入本地素材显示,我们的 Resources 实例重写过可以确保替换显示占位 drawable/image,测试中发现一旦使用 Glide 载入本地素材,就显示一片空白,为避免修改众多的业务代码导致测试周期拉长,又开始埋头阅读 Glidde 源代码。熟悉内核代码后发现,Glide 载入本地图片不是使用 Resources 实例,而是 Uri 定位符,Glide 之所以这么写是为了统一代码框架便于扩展。认真阅读 Glide 扩展规则,重写了 Local Uri 方法,优先从已下载文件中寻址素材,返回 File Uri 解决了问题。

踩坑九:自动化打包脚本的编写历程

如果觉得资源发布管理还算问题嘛,不就是上传下配置下嘛,请看看起初的经历。绝大部分工作完成后,着手准备 20 多个 zip 文件,计算低版本增量更新包,,获取各个 zip 文件的 md5,最后把这么多信息写进配置文件里,上传到云端。就这么简单的人力工作,耗费了大量的时间精力,做完了心里还忐忑不安,如果手动发布配置出错,线上一定出事故,还需要考虑不清楚技术细节的小伙伴也能快速发布依赖附件包。这种场景类似于当初尝试插件化碰到的问题,非技术问题:版本迭代管理成本。

考虑打 Release 包时通过 Jekins 托管,打包完毕后 Jekins 上已经输出 20 多个业务的 zip 文件,为什么不写个 Python 脚本,命令行运行,自动发布附件包到云端?有了想法开始各种倒腾,首先配置 Jekins Web 环境确保 HTTP 可以访问,Python 脚本大约流程如下,按照配置清单从 Jekins 上下载 20 多个工程的 zip 附件,对比历史版本 zip 附件产生低版本增量包,计算各包 md5 校验值,批量自动化上传到 OSS,汇总各个文件链接 校验信息 增量信息产生 config 文件在发布到云端。经过 3 天反复的编码,测试确保脚本 OK 了,开始使用完整的流程。一切看似正常,突然发现若干素材显示变形失真了。再次埋头去定位问题,发现失真的图片是 ninePatch 图片,熟悉安卓的小伙伴知道 ninePatch 是特殊的 png 图片,在 studio 中按照规则编辑边缘就能使用最小尺寸的图片显示大尺寸确不失真。想这种特殊图片一定在正常编译中有特殊处理,再次开始研究 gradle 编译流程,发现对于 ninePatch 素材,gradle 会调用 aapt 程序计算 chunk 信息保存在图片的 metadata 中,那 python 是否可以调用 aapt 工具对附件的 ninePatch 素材进行编译呢,又耗费精力在 Python 脚本中加入 aapt 编译再次尝试,问题解决了。自我感觉是没问题了,然而几天后运行 Python 脚本时发现,整个运行了 2 个多小时才发布完毕。。。又开始逐步调试,发现随着迭代版本增多,计算 6500 多张图片增量包 IO 操作太多,最终优化算法减少 IO 次数解决问题。

10. 方案能成功的经验总结

1. 基于 Android 标准接口重写,避免 Hook 技术获得很好的兼容性,特别是后续系统兼容上。

2. 发版阶段不需要各个业务方独立打附件包,而插件化的方式需要独自打附件包

3. 在资源下载更新上我们做到了存量用户增量更新,而插件化的方式无法做到

4. 除了技术本身我们做到了打包 发布 优化 增量等环节的自动化实现,节约迭代成本

5. 我们在图片资源替换显示上做到了无缝替换,最大程度的降低了业务代码修改量

6. 方案实施完毕后,后续的新增项目和需求不再导致 APP 持续增长,长期稳定。

7. 我们在构造下发框架做到抽象统一 针对 Bug 修改时也在底层完成兼容,降低成本

8. 释放了开发资源,大规模的自测确保质量。

虽然我们砍掉了一大半的体积,但是持续减包,持续减少资源体积,优化产品体验还需要坚持下去。后期进入深水区,可以推荐如下研究方向:

  1. 短期拆分直播工程,把原本 50m 的直播资源分散开来,进入不同的直播课时 loading 的时间会更少。
  2. 中期项目组需要筹划混淆实施方案,尽量统一素材,动效统一,在 UI 设计上最大化统一。同时考虑脚本化分析代码,祛除无用代码,统一相似代码。
  3. 长期考虑 dex 优化,目前考虑到 APP 的稳定性,没有对 dex 启动混淆。
  4. 补充优化,可以考虑引用运动适量还原技术替换现有的帧动画 gif 动画,大约能减少 60% 的动效体积。
  5. 补充优化,研究轻量超分重建,难度大收益大
  6. end

作者简介

袁威为好未来高级 Android 工程师 III

招聘信息

好未来技术团队正在热招测试、后台、运维、客户端等各个方向高级开发工程师岗位,大家可扫描下方二维码或微信搜索“好未来技术 ”,点击本公众号“ 技术招聘”栏目了解详情,欢迎感兴趣的伙伴加入我们!

也许你还想看

DStack– 基于 flutter 的混合开发框架

GPU 计算的基本概念

WebRTC 源码分析——视频流水线建立(上)

浅析深度知识追踪如何助力智能教育

轻量型 TV 端遥控器交互类库最佳实践

“ 考试 ” 背后的科学:教育测量中的理论与模型(IRT 篇)

用技术助力教育 | 一起感受榜样的力量

想了解一个异地多校平台的架构演进过程吗?让我来告诉你!

摩比秀换装游戏系统设计与实现(基于 Egret+DragonBones 龙骨动画)

如何实现一个翻页笔插件

产研人的疫情战事,没有一点儿的喘息

退出移动版