阅读 610

Android 颜色色值与 alpha 分离解决方案

一、背景

目前 Android 并不支持 xml 文件中颜色与透明度分开定义,如果想用带透明度的颜色值,只能在 colors.xml 文件中定义一个新色值。比如,有一个颜色名字叫 N900,定义如下:

<color name="N900">#1F2329</color>
复制代码

当我需要一个 50% 透明度 N900 的颜色时,只能自己定义再一个色值:

<color name="N900_alpha_50">#7F1F2329</color>
复制代码

于是,colors.xml 内就出现很多不规则颜色,就像下面这样子:

不规则颜色

并且还会继续增加这些不规则颜色。当下次换颜色时,这些带透明度的颜色每一个都需要更换,维护起来十分麻烦。并且,这些颜色目前所在的module已经打成aar,每次如果需要增加新的颜色,都需要重新打包aar上传,十分影响开发效率。

为了解决上述问题,开发了ResKitPlugin 插件,在编译时期动态替换颜色,支持颜色与透明度分开定义。

二、技术原理

基本思路是在aapt最终打包前,替换资源编译后生成的文件,使 aapt 最后打包时使用的资源二进制文件内的相应的颜色值已经带上了透明度。先上一张图:

流程图

下面详细介绍:

(一).相关背景知识介绍

  1. 目前我们的资源编译使用的都是aapt2, aapt2 编译资源分两步:
  • 编译:将资源文件编译为.flat 文件
  • 链接:将.flat 文件链接为最终的二进制资源文件.ap_
  1. gradle 在编译apk 时,是执行一系列Task,并且有些Task 是有严格先后顺序的。
  2. 我们需要关心的是其中两个和资源编译相关的Task :
  • mergeDebugResources : 这个Task 是负责收集所有的资源文件并使用aapt2编译成.flat文件,放在build/intermediates/res/merged/{*flavor*}/{buildType}目录下。
  • processDebugResources:这个Task是负责 使用aapt2 将.flat文件 链接为最终的二进制资源文件
  • mergeDebugResources 与 processDebugResource有严格的先后顺序,先执行mergeDebugResources,后执行 processDebugResource
  1. gradle 的API支持 “改变Task的执行顺序" 的操作
  2. MergeResources 类的 computeResourceSetList 方法可以获取编译要用的全部 res 路径
  3. mergeDebugResources 执行后,所有res/values目录下的内容,都会合并到一个文件内,放在build/intermediates/incremental/merge${variantName}Resources/merged.dir/values/values.xml内,这里面包括了定义的颜色资源。

(二).实现步骤

  1. 首先,定义一个自己的Task,叫做handleAlphaColorTask,负责修改.flat文件
  2. 通过gradle 的 API , 将handleAlphaColorTask 插入 mergeDebugResources 与 processDebugResources之间,这一步执行后,gradle 的编译Task 的 调用顺序如下:

流程图

  1. 在handleAlphaColorTask内处理mergeDebugResources生成的文件,使其内部的颜色属性带上了透明度,例子如下:

最开始,drawable_a.xml.flat 文件是由(代码-1)编译生成:

   <solid android:color="@color/N900" android:alpha="0.5" />
复制代码

通过我们的处理,drawable_a.xml.flat 文件 变成了由(代码-2)编译生成:

  <solid android:color="@color/reskit_tmp_color_N900_alpha_0_5" />
复制代码

对于硬编码的颜色,会直接进行如下转换:

  <solid android:color="#1F2329" android:alpha="0.5" />
              |
             \|/
  <solid android:color="#7F1F2329"/>
复制代码

通过这样的处理,我们的颜色在运行时就拥有了透明度。下面介绍具体处理的步骤。

(三). 颜色转换的具体实现方式

通过computeResourceSetList去获取到所有参与编译的资源文件,然后修改源码,编译生成新的.flat文件,并替换原来的.flat文件。分为以下几步:

  1. 通过反射调用MergeResources 的 computeResourceSetList 方法,获取参与编译的全部 res 文件夹路径,包括aar内的。
  2. build/intermediates/incremental/merge${variantName}Resources/merged.dir/values/values.xml内挑出所有的颜色定义并生成colors.xml,为后续根据id找颜色值提供基础。
  3. 遍历所有 res 文件夹下的xml 文件。
  4. 通过xml 解析,识别 颜色属性和与之配对的透明度属性,并通过计算生成最终的颜色值。如果颜色属性是引用属性,则去colors.xml 根据引用id 找到对应的色值,然后计算出最终颜色。计算出最终颜色后,需要替换颜色属性,进行替换时有以下两个策略
    • 原颜色属性是硬编码颜色时,如 android:color ="#1F2329",则直接修改值即可。
    • 原颜色属性是引用颜色时,如android:color="@color/lkui_N900",会生成一个新key,然后将其替换为新key,并把这个新key与颜色的对应关系存在一个Map里,待新key 全部生成后,统一将新key 与颜色的对应关系写入build/intermediates/incremental/merge${variantName}Resources/merged.dir/values/values.xml文件,参与后续编译。
  5. 遍历期间,将需要修改的文件,则保存下来,放到一个Map里,这么做的目的是当出现同名资源时,提供筛选资源的数据。Map的定义如下:
Map<String, Map<String, String>>

资源文件的父文的名字 + “/” + 资源文件的名字  :  [  原始文件全路径  : 处理了alpha 后的新文件的全路径  ]

举例:
drawable/aab.xml : [/Users/guoxiao/ResPluginDemo/app/src/main/res/drawable/aab.xml  :  /Users/guoxiao/ResPluginDemo/app/build/coloralpha/res/drawable/aab.xml]
复制代码
  1. 遍历完成后,我们就得到了一个Map和在指定目录下合并了alpha属性的资源 源码文件,接下来,需要处理重名资源
  2. 我们需要知道在intermediates/res/merged/{*flavor*}/{buildType}目录下,对于重名文件来说,系统究竟使用了哪个文件去参与编译的。这里使用的方法是:
    • 在第5步获得的map里,可以知道有几个重名文件,全路径是什么,对应的新的修改后的文件是什么。
    • 编译每一个重名的原文件,生成.flat文件,然后和intermediates/res/merged/{flavor}/{buildType}目录下的同名文件做md5比较,比较结果相同的,说明找到了系统编译使用的文件
    • 找到了系统编译是用的文件,我们就知道了最后我们应该编译哪个新的修改了属性的文件去替换原.flat文件
  3. 重名文件处理完成后,就可以编译新的修改后的文件,产生新的.flat 文件
  4. 用新的.flat 文件 替换老的.flat 文件。
  5. 如果本次编译没有修改资源文件,即intermediates/res/merged/{flavor}/{buildType}目录下的文件的md5和上次一致,则直接使用上次的缓存的.flat文件进行替换

三.对编译性能的影响

我们缓存了上次颜色处理得到的.flat文件,对于本次颜色处理:

  1. 未命中缓存时:
    • 在CI 平台上测试:替换了78个.flat文件,用时13991ms,其中,替换资源文件用时10135ms(未指定文件夹过滤,此时是最坏情况,全量遍历), 编译用时352ms。一个文件的编译时间4.5ms左右。
    • 本地测试:替换了78个.flat文件,指定文件夹过滤 ,耗时6192ms,替换颜色3995m,编译255ms;未指定文件夹过滤,耗时9179ms左右, 替换颜色 7392 ms, 编译 184ms。
  2. 命中缓存时,耗时500ms左右

四.探索的过程

目前的实现并不是最初的方案,测试时,替换7个文件,耗时从30s ,到20s, 最终优化到现在的5s左右。下面介绍这期间经历的几个方案:

  1. 最初方案是在processDebugResources 后插入颜色处理Task:
    1. 获取到系统连接后的资源文件包.ap_
    2. 通过ApkTool反编译.ap_文件,得到源码
    3. 修改源码
    4. 重新编译新修改的源码,获得新的.flat文件
    5. 将新的.flat文件与系统编译产生的.flat文件一起参与链接,生成最终的二进制文件
    6. 用新获得的由新的源文件编译链接而产生的二进制文件提替换.ap_文件内的文件
    7. 处理完成

这种方式,一次链接耗时7s左右,而且为了链接,还需要做一些压缩与解压的文件操作,压缩全部.flat文件需要7秒多,反编译.ap_又需要7秒多,最终一次颜色处理下来,耗时30s左右。

  1. 第二种方案,在mergeDebugResources 和 processDebugResources之间插入颜色处理Task:
    1. 获取mergeDebugResources 后生成的.flat文件,链接这些.flat文件,生成tmp.ap_包
    2. 反编译tmp.ap_,得到源码
    3. 修改源码
    4. 重新编译修改了源码的文件,得到.flat文件
    5. 替换mergeDebugResources 生成的同名.flat文件
    6. 处理完成

这种方式,可以去除对系统生成的.ap_文件的修改,耗时20s左右。

​ 前两种方案耗时,主要是进行链接和反编译,从而得到源码。于是思考,有没有可能不通过链接和反编译的方式来得到源码,最终有个方案3。

  1. 第三种方案,依然是 在mergeDebugResources 和 processDebugResources之间插入颜色处理Task,区别于第二种方案,是通过反射MergeResource的 computeResourceSetList 得到所有参与编译的资源文件(/res):
    1. 通过反射调用computeResourceSetList 获得所有的资源目录
    2. 遍历资源目录,如果需要处理颜色,则拷贝一份新文件,然后处理并保存到指定目录build/coloralpha/res
    3. 处理完成后,编译build/coloralpha/res生成新的.flat文件
    4. 用新的.flat文件替换系统编译生成的同名.flat文件
    5. 处理完成

最终耗时5s 左右。

处理完成后,系统的processDebugResources就会使用我们处理过的.flat文件。

五.特别注意的坑

mergeResource Task 若使用了 gradle 的构建缓存(运行该Task 会输出 FROM_CACHE) ,会缺失这个Task的中间产物,即merged.dir文件夹为空。

针对这种情况,我们每次在MergeResource执行前判断是否有merged.dir,若没有,不让它走 FROM_CACHE。

具体做法是:临时生成一个资源文件,导致缓存失效,这样就会触发mergeResources走一遍,然后 在mergeResource之后删除我们临时生成的资源文件。

六.一些想法

由于我们可以拿到参与编译的所有资源文件,也可以修改替换系统编译产生的文件。这两个能力,提供了巨大的想象空间,如:

  1. 可以做全局资源查重,包括aar内的资源
  2. 可以做资源压缩,如压缩图片
  3. 可以支持更多的类似color,alpha这样的组合属性的自定义
  4. 可以根据编译环境修改string.xml 内容
  5. 编译过程中自动收集生成皮肤包

文章作者:国霄(EE Lark Android 团队)

邀请优秀的人一起做有挑战的事儿!字节跳动效率工程团队研发职位招聘,想成为技术大牛的伙伴快点进来看!职位介绍

关注下面的标签,发现更多相似文章
评论