APP为什么要减包?
APP体积越大推广转化成本越高,因为平台功能众多,学而思网校的APP体积是在144m左右,疫情期间由于公益直播涌入大量用户,转换率上的硬伤更加暴露出来。同时移动部设定了自我突破的若干指标,转换率是关键指标,背负紧急军令我们开始了减包任务,一定要做到70m。
为什么不用插件化?
19年团队曾经尝试过插件化技术,经过两个项目试水碰到一系列问题,最终放弃使用插件化,原因如下:
插件技术原理是通过Hook或者Reflect技术修改系统libs和framework代码,Android系统版本 设备 ROM众多,Hook Reflect很难100%兼容。
学而思网校平台有20+的二级工程,一个工程变更重新打包时,插件资源id的重新分配,整体工程变更导致20多插件变动需要重新维护,维护人力成本有点大。
插件技术使用时存在数据传递问题 自定义UI显示问题,权限重复申请等问题。
插件化的核心是ClassLoader,按照谷歌的文档,最快Android 12将会被限制, 未来有不确定性。
减包计划实施难度?
- 涉及到20+的二级工程 资源类型众多 调用代码分布广泛,要求在底层框架统一实现核心技术。
- 需要兼容Android4.4到最新的版本系统,同时核心技术兼容后续系统迭代。设备上需要兼容各个手机品牌的高中低,兼容任务繁重。
- 产品迭代迅速,为了避免后续开发导致APP慢慢滋长,需要设计统一的技术框架保持持久轻量。
- 总体开发时间一周,测试一周,各个业务线还在并行开发,为了保障时间节点,技术框架需要做到最小的业务代码代动。
减包前APP体积汇总。
通过数据统计发现,20多个工程的res图片资源 assets的lottie动效资源 libs下的so文件合计约有70m。其他零散的100kb文件有6m左右。20多个二级功能,其资源一次性打进APP里是不合理的,毕竟用户常用的就那么几个。为什么不把资源分离出来托管到云端,使用时再拉取呢?想法很简单,但是面临一系列的问题,我们有6000多张图片,托管CDN的话,业务代码都要修改访问链接不现实。一个想法在内心产生,可以做一个离线附件的技术框架嘛。
附件框架的方案
附件框架:开发时资源打进APP不影响业务方开发调试预览;发版时指定的资源统一分离出来托管到云端,进入对应功能前确保资源包下载完了,运行阶段不受影响。文字虽短,框架层需要支持一下特性:
资源分离需要做到脚本自动化,并且只分离指定目录的资源文件,分离出来的zip应该是多个,并且和20多个工程形成一一对应关系。
资源下载需要做到按需下载,进入哪个功能下载哪个资源,避免一次性全部下载导致的loading时间太长。为了减少loading出现,需要根据业务权重做后台预加载机制。
框架层面在保证按需下载的前提下,实现业务层面的统一拦截下载,以避免大量的业务代码修改和调试,做到业务方无感知框架。
以前资源在APP内,附件框架的资源在下载后,框架代码需要做到全方面的资源访问替换技术,以避免大量的业务代码变动,做到业务层面无感知。
考虑到存量用户基数大,各个业务版本迭代资源变动小,为了进一步避免或减少loading出现的概率时间,附件框架可以做增量更新技术。保证存量用户更新资源时,资源包体积减少95%。
20多个离线zip增量迭代10个版本,会产生上百个资源文件,对应的人力维护成本也大。需要配套的自动化附件包发布脚本,一是减轻负重,二是避免人为性失误。
- 框架需要考虑失败重试机制 需要做到多云备份预防网络事故 需要做到内置外置卡双存储避免极端情况。需要完整的日志链条以持续优化。
资源分离技术说明
首先规定了附件目录attach, gradle脚本会给每个二级工程生成该目录。业务方只需要把lottie so以及其他大文件移动到附件目录,不需要修改代码。
Jekins打release包时,分离脚本启用了,gradle脚本会自动遍历二级工程:每个工程res下的图片文件会打到zip,源文件会用xml文件占位替换,每个工程的attach文件会打包到zip中。
- 最终Jekins产生了20+的zip文件,打包完成后命令行运行脚本,自动化发布资源文件到云端。
资源发布自动化技术
- 批量编译点九图 确保APP使用时无失真拉伸
- 批量使用熊猫 WEBP技术对图片文件优化 以减少资源体积
- 自动对比历史版本归档记录 产生对应的增量更新文件
- 同时发布多个资源包到案例云和腾讯云 双云避免网络事故
使用python脚本自动化发布做到人力不及的流程,避免了类似于插件化维护的管理成本。
抽象统一的下载框架
底层框架统一拦截跳转,确定需要进入的二级模块,检查下载对应资源文件,下载后继续跳转。统一实现了20+业务的核心代码,避免业务改动。
下载环节做到网络错误感知,阿里云腾讯云自动切换,4次失败重试避免网络事故。文件存储时优先内置卡,次要外置卡存储,避免极端的文件读写问题。
- 框架层面统一文件管理,版本迭代管理,避免修改业务代码。同时增量更新确保用户最小的下载量。
资源访问的无缝替换
附件资源分离做到自动化 发布做到自动化 下载做到了抽象统一。再做到无缝替换技术,基本上业务代码变更就很微小。所谓无缝替换,就是从关键接口层面统一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这种重量级内核了吧,毕竟用户看的是屏幕,产品要的是实现了而不是怎么实现。抱着试一试的心态,开始编写这个工具,中途也遇到了些问题。
- 时间平滑问题:动画效果很重要一点就是帧之间的时间平滑度,起初的程序控制设定在30ms一帧采集,但是发现实际的采集结果有的是30ms,有得是200ms,时间平滑度出入太大效果不理想。通过时间数据采集,发现采集后编码PNG时间,文件IO时间变动,中间又有系统内存回收导致的。再次修改采集方法,采用双线程模型加高缓存策略,保证了时间平滑度在30ms左右。
- 祛除背景问题:截屏窗体采用纯白背景0XFFFFFFFF,设想对截屏图片使用程序去除白色部分,然而发现有些色素是有Alpha通道的。理论上讲白色可以和任意Alpha通道色值混合成目标色值。这就意味着还原Alpha通道色值有些不现实,再次陷入困境。。。查阅了颜色混合公式 Dst = (Src * Alpha + (256 – Src.Alpha * Alpha / 255) * Dst ) / 255, 联想到对于同一帧如果分别采用白色背景和红色背景,利用混合模式对比不就能还原出色素的Alpha和RGB值嘛。于是再次修改采集程序,一个动作分别用红色背景和白色背景采集,生成两套动作。编写相似度算法分别找出每一帧的红色图和白色图,反向色素混合,果然能还原Alpha通道和RGB值~
- 祛除噪点问题:在祛除背景还原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次数解决问题。
方案能成功的经验总结
1.基于Android 标准接口重写,避免Hook技术获得很好的兼容性,特别是后续系统兼容上。
2.发版阶段不需要各个业务方独立打附件包,而插件化的方式需要独自打附件包
3.在资源下载更新上我们做到了存量用户增量更新,而插件化的方式无法做到
4.除了技术本身我们做到了打包 发布 优化 增量等环节的自动化实现,节约迭代成本
5.我们在图片资源替换显示上做到了无缝替换,最大程度的降低了业务代码修改量
6.方案实施完毕后,后续的新增项目和需求不再导致APP持续增长,长期稳定。
7.我们在构造下发框架做到抽象统一 针对Bug修改时也在底层完成兼容,降低成本
8.释放了开发资源,大规模的自测确保质量。
虽然我们砍掉了一大半的体积,但是持续减包,持续减少资源体积,优化产品体验还需要坚持下去。后期进入深水区,可以推荐如下研究方向:
- 短期拆分直播工程,把原本50m的直播资源分散开来,进入不同的直播课时loading的时间会更少。
- 中期项目组需要筹划混淆实施方案,尽量统一素材,动效统一,在UI设计上最大化统一。同时考虑脚本化分析代码,祛除无用代码,统一相似代码。
- 长期考虑dex优化,目前考虑到APP的稳定性,没有对dex启动混淆。
- 补充优化,可以考虑引用运动适量还原技术替换现有的帧动画 gif动画,大约能减少60%的动效体积。
- 补充优化,研究轻量超分重建,难度大收益大
- end
作者简介
袁威为好未来高级Android工程师III
招聘信息
好未来技术团队正在热招测试、后台、运维、客户端等各个方向高级开发工程师岗位,大家可扫描下方二维码或微信搜索“好未来技术”,点击本公众号“技术招聘”栏目了解详情,欢迎感兴趣的伙伴加入我们!
也许你还想看
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。