APK包资源精简 立减1M

moon 2年前
   <p>Apk包大小是Android优化的一项重要指标,本文主要从资源方面着手,提出一些优化的新思路。</p>    <h2>无用资源精简</h2>    <p>项目随着开发迭代,会遗留大量的无用代码和资源,今天主要说一下无用资源如何精简。资源精简最重要的是无用资源的检索,常规检索方式有lint的unused resource,gradle开启shrinkResources。但用lint仅仅检测出了十几个,效果不明显,开启shrinkResources后,对包大小的影响也在几K级别。Google把shrinkResources集成到了打包流程中,为什么很多无用资源都检索不出来呢,带着这些疑问,我决定仔细研究一下原理。</p>    <p>开启shrinkResources后,打包过程会新增task transformClassesWithShrinkResFor{variant},gradle1.5之后只需要注册一个tranform就会产生一个对应的task,查看源码发现对应的tranfrom在com.android.build.gradle.internal.transforms.ShrinkResourcesTransform,此类中调用com.android.build.gradle.tasks.ResourceUsageAnalyzer的analyze方法进行分析无用资源。</p>    <pre>  <code class="language-java">public void analyze() throws IOException, ParserConfigurationException, SAXException {              gatherResourceValues(mResourceClassDir);              recordMapping(mProguardMapping);              recordClassUsages(mClasses);              recordManifestUsages(mMergedManifest);              recordResources(mMergedResourceDir);              keepPossiblyReferencedResources();              dumpReferences();              mModel.processToolsAttributes();              mUnused = mModel.findUnused();          }</code></pre>    <p>该方法首先会根据R文件来生成资源表,然后利用asm遍历所有的class分析class中用到的R,然后将资源表中相应资源标记为可达,接着分析Mainfest,Res文件找出资源文件中引用的其它资源。最终调用keepPossiblyReferencedResources,此方法是标记可能会引用的资源,项目中调用了getIdentifier,参数是通配符,资源名称只要匹配就会标记为可用。</p>    <p>查看编译后的文件build/outputs/mapping/release/resources.txt,shrinkResources相关的日志都会在此文件中,有大量资源因为keepPossiblyReferencedResources被标记为可达。</p>    <p><img src="https://simg.open-open.com/show/96e3ce44fcff78fcaf1824a0ca77a27f.png"></p>    <p>从原理上分析,这种查找无用资源的方式是可行的,只是需要稍微改动。默认情况下shrinkResources是安全模式,可能会被使用的资源也被标记为了可达;关闭安全模式,开启严格模式,只有真正通过代码或是资源文件引用的资源才被标记为可达。混淆配置添加-dontshrink -dontoptimize,系统是分析混淆后的类,如果一个类被压缩掉了,它引用的资源就会被标志为不可达,这时候如果仅仅删除资源,后续就编译通不过了。</p>    <p>在res目录中添加keep.xml,设置严格模式。</p>    <pre>  <code class="language-java"><?xml version="1.0" encoding="utf-8"?>  <resources xmlns:tools="http://schemas.android.com/tools"      tools:shrinkMode="strict" /></code></pre>    <p>经过上述配置改动后,重新编译查看输出文件,可以看到大量的无用资源。打包过程是将其替换为了一个同名的空文件,但我们可以解析这个文件,找到无用资源,用脚本批量删除。</p>    <p><img src="https://simg.open-open.com/show/56b0c86705dadc52e27c49f03f109f8f.png"></p>    <p>头条app客户端原始15M,通过脚本批量删除了600+资源,包大小减小0.47M。不同项目效果不同。</p>    <h2>重复资源精简</h2>    <p>Android开发推崇根据业务拆分多个模块,模块间为了防止资源覆盖,会给每一个模块的资源加一个前缀,同样的资源就会在apk包中出现多次。阅读微信资源混淆源码时发现,它将每个资源Chunk中的资源路径替换为了一个较短的路径。那么对于重复资源,仅仅保留一份,修改arsc文件,重定向Chunk对应的资源路径,就可以解决重复资源问题。</p>    <p>打包过程中ProcessAndroidResources这个Task会生成资源文件/build/intermediates/res/resources-release.ap_,该文件是一个zip文件,解压后包括AndroidManifest.xml,res,resources.arsc几部分。res目录中的文件即是最终要打入到apk中的,resources.arsc即为最终的arsc文件。</p>    <p>解压ap_文件,遍历res目录中的文件,根据每个文件的md5值,找出重复的文件。最终发现主要有两种重复的情况,一种是文件名相同,但在不同的dpi目录下;一种是内容相同,名字不同。删除重复文件,保留一份,然后利用ChunkUtil这个库来修改arsc文件,ChunkUtil是一个arsc编辑库。</p>    <p>重复资源处理,作为一个gradle插件,后续会开源给大家作为参考。</p>    <p>重复资源处理后,资源映射如下所示,每个资源代码一个chunk,假如以下3个chunk中的资源相同,则处理后它们会指向相同的路径。</p>    <p><img src="https://simg.open-open.com/show/e4fd01db40c1e81096fae59875030ad9.png"></p>    <p>经过打包期间删除重复资源,共删除了300+资源,包大小减小了0.28M,不同的项目效果不同。</p>    <h3>重复资源处理与微信资源混淆冲突</h3>    <p>项目中如果使用了微信资源混淆,打包失败。如果你的项目中没使用微信资源混淆,就没必要看后面的问题了。</p>    <p><img src="https://simg.open-open.com/show/4cd3b0f231c4381857d0149a61f1d4ec.png"> 根据错误堆栈可以定位到微信资源混淆出现的位置。阅读微信资源混淆源码,发现映射关系如下:从res/drawable/icon.png到r/a/c.png是一一映射的。每个资源路径可变的有两部分,一是资源类型如drawable,color;另一个是资源名称。映射过程有以下规则,同类型资源会映射到相同目录中,资源id相同也即是同名资源映射后的资源名相同。如下图中,资源1和2是名字相同,映射后的名字都是c.png,资源2和资源3资源类型相同,映射后的资源都在r/b目录下。</p>    <p><img src="https://simg.open-open.com/show/8189e047b8d8f763e580595567fe0cde.png"></p>    <p>这时候将重复资源处理和微信混淆流程串联起来如下所示:</p>    <p><img src="https://simg.open-open.com/show/c25872d544097639029b809b625a124a.png"></p>    <p>资源1和资源2映射后的目标路径相同。微信资源混淆会遍历每个chunk,把每个chunk中的资源从原始目录copy到目标目录并重新命名为映射后的文件,copy前check目标文件是否存在,如果存在会出现上述错误。</p>    <p>微信资源混淆的目标路径映射规则是根据id值映射的,不是根据原始路径。因此我们需要改变默认的映射规则,如果原始路径相同,则映射到相同的目标路径,并且不做后续的copy工作。修改了映射逻辑后,资源3最终映射的路径也变为了r/a/c.png。</p>    <p>重新打包,发现还是出现上述错误,只是出错的资源不同。这时候如果有第四个资源,和前面3个资源内容不相同。资源类型和资源1相同,所以映射成了r/a/目录,名称和资源3相同,所以最终映射成了r/a/c.png,又导致了上述目标地址重复的问题。这种情况需要对路径进行remapping。</p>    <p><img src="https://simg.open-open.com/show/6249381b113ca8f91f7ab59f2042f89c.png"></p>    <p>对微信资源处理的逻辑全在com.tencent.mm.androlib.res.decoder.ARSCDecoder的readValue函数中。</p>    <pre>  <code class="language-java">private void readValue(boolean flags, int specNamesId) throws IOException, AndrolibException {          /* size */          mIn.skipCheckShort((short) 8);          /* zero */          mIn.skipCheckByte((byte) 0);          byte type = mIn.readByte();          int data = mIn.readInt();            //这里面有几个限制,一对于string ,id, array我们是知道肯定不用改的,第二看要那个type是否对应有文件路径          if (mPkg.isCanProguard() && flags && type == TypedValue.TYPE_STRING && mShouldProguardForType && mShouldProguardTypeSet.contains(mType.getName())) {              if (mTableStringsProguard.get(data) == null) {                  String raw = mTableStrings.get(data).toString();                  if (StringUtil.isBlank(raw)) return;                    // 相同原始路径映射到相同目标地址                  String oldResult = mRawToResult.get(raw);                  if(oldResult != null){                      mTableStringsProguard.put(data, oldResult);                      return;                  }                    String proguard = mPkg.getSpecRepplace(mResId);                  //这个要写死这个,因为resources.arsc里面就是用这个                  int secondSlash = raw.lastIndexOf("/");                  if (secondSlash == -1) {                      throw new AndrolibException(                          String.format("can not find \\ or raw string in res path=%s", raw)                      );                  }                    String newFilePath = raw.substring(0, secondSlash);                    if (!mApkDecoder.getConfig().mKeepRoot) {                      newFilePath = mOldFileName.get(raw.substring(0, secondSlash));                  }                  if (newFilePath == null) {                      System.err.printf("can not found new res path, raw=%s\n", raw);                      return;                  }                  //同理这里不能用File.separator,因为resources.arsc里面就是用这个                  String result = newFilePath + "/" + proguard;                  int firstDot = raw.indexOf(".");                  if (firstDot != -1) {                      result += raw.substring(firstDot);                  }                  String compatibaleraw = new String(raw);                  String compatibaleresult = new String(result);                    //为了适配window要做一次转换                  if (!File.separator.contains("/")) {                      compatibaleresult = compatibaleresult.replace("/", File.separator);                      compatibaleraw = compatibaleraw.replace("/", File.separator);                  }                    File resRawFile = new File(mApkDecoder.getOutTempDir().getAbsolutePath() + File.separator + compatibaleraw);                  File resDestFile = new File(mApkDecoder.getOutDir().getAbsolutePath() + File.separator + compatibaleresult);                  // 相同地址remapping                  if(resDestFile.exists()){                      // re proguard                      result = newFilePath + "/" + mProguardBuilder.getReplaceString();                      firstDot = raw.indexOf(".");                      if (firstDot != -1) {                          result += raw.substring(firstDot);                      }                      compatibaleresult = new String(result);                      if (!File.separator.contains("/")) {                          compatibaleresult = compatibaleresult.replace("/", File.separator);                      }                      resDestFile = new File(mApkDecoder.getOutDir().getAbsolutePath() + File.separator + compatibaleresult);                  }                    //这里用的是linux的分隔符                  HashMap<String, Integer> compressData = mApkDecoder.getCompressData();                  if (compressData.containsKey(raw)) {                      compressData.put(result, compressData.get(raw));                  } else {                      System.err.printf("can not find the compress dataresFile=%s\n", raw);                  }                    if (!resRawFile.exists()) {                      System.err.printf("can not find res file, you delete it? path: resFile=%s\n", resRawFile.getAbsolutePath());                      return;                  } else {                      if (resDestFile.exists()) {                          printRawToResult();                          throw new AndrolibException(                              String.format("res dest file is already  found: destFile=%s", resDestFile.getAbsolutePath())                          );                      }                      FileOperation.copyFileUsingStream(resRawFile, resDestFile);                      //already copied                      mApkDecoder.removeCopiedResFile(resRawFile.toPath());                      mTableStringsProguard.put(data, result);                      mRawToResult.put(raw,result);                    }              }          }      }        Map<String,String> mRawToResult = new HashMap<>();        void printRawToResult(){          Set<String> keySets = mRawToResult.keySet();          for(String s: keySets){              System.out.println("printRawToResult raw " + s + " dest " + mRawToResult.get(s));          }      }</code></pre>    <p> </p>    <p>来自:https://techblog.toutiao.com/2017/09/20/untitled-11/</p>    <p> </p>