Android资源学习(六)apk包体积优化
资源混淆
aapt
通过修改aapt在生成resources.arsc和*ap_时把资源文件的名称进行替换
tatic status_t makeFileResources(Bundle* bundle, const sp<AaptAssets>& assets,
ResourceTable* table,
const sp<ResourceTypeSet>& set,
const char* resType)
{
String8 type8(resType);
String16 type16(resType);
bool hasErrors = false;
ResourceDirIterator it(set, String8(resType));
ssize_t res;
while ((res=it.next()) == NO_ERROR) {
if (bundle->getVerbose()) {
printf(" (new resource id %s from %s)\n",
it.getBaseName().string(), it.getFile()->getPrintableSource().string());
}
String16 baseName(it.getBaseName());
const char16_t* str = baseName.string();
const char16_t* const end = str + baseName.size();
while (str < end) {
if (!((*str >= 'a' && *str <= 'z')
|| (*str >= '0' && *str <= '9')
|| *str == '_' || *str == '.')) {
fprintf(stderr, "%s: Invalid file name: must contain only [a-z0-9_.]\n",
it.getPath().string());
hasErrors = true;
}
str++;
}
String8 resPath = it.getPath();
resPath.convertToResPath();
String8 obfuscationName;
String8 obfuscationPath = getObfuscationName(resPath, obfuscationName);
table->addEntry(SourcePos(it.getPath(), 0), String16(assets->getPackage()),
type16,
baseName, // String16(obfuscationName),
String16(obfuscationPath), // resPath
NULL,
&it.getParams());
assets->addResource(it.getLeafName(), obfuscationPath/*resPath*/, it.getFile(), type8);
}
return hasErrors ? UNKNOWN_ERROR : NO_ERROR;
}
在ResourceTable和Assets中添加资源文件时, 对资源文件名称进行修改,这就能够做到资源文件名称的替换,这样通过使用修改过的AAPT编译资源并进行打包
AndResGuard
mark下之前在做源码解析时的笔记,点开就可以查看当时的笔记链接嘿嘿
移除无用资源
RemoveUnusedResourcesTask-> removeUnusedResources
void removeUnusedResources(String originalApk, String rTxtFile, SigningConfig signingConfig) {
ZipOutputStream zipOutputStream = null;
boolean needSign = project.extensions.matrix.removeUnusedResources.needSign
boolean shrinkArsc = project.extensions.matrix.removeUnusedResources.shrinkArsc
String apksigner = project.extensions.matrix.removeUnusedResources.apksignerPath
if(needSign) {
// 签名信息和签名工具有效性校验
}
try {
File inputFile = new File(originalApk);
Set<String> ignoreRes = project.extensions.matrix.removeUnusedResources.ignoreResources;
for (String res : ignoreRes) {
ignoreResources.add(Util.globToRegexp(res));
}
Set<String> unusedResources = project.extensions.matrix.removeUnusedResources.unusedResources;
Iterator<String> iterator = unusedResources.iterator();
Set<String> unusedResources = project.extensions.matrix.removeUnusedResources.unusedResources;
Iterator<String> iterator = unusedResources.iterator();
String res = null;
while (iterator.hasNext()) {
res = iterator.next();
if (ignoreResource(res)) {
iterator.remove();
Log.i(TAG, "ignore unused resources %s", res);
}
}
Log.i(TAG, "unused resources count:%d", unusedResources.size());
String outputApk = inputFile.getParentFile().getAbsolutePath() + "/" + inputFile.getName().substring(0, inputFile.getName().indexOf('.')) + "_shrinked.apk";
File outputFile = new File(outputApk);
if (outputFile.exists()) {
Log.w(TAG, "output apk file %s is already exists! It will be deleted anyway!", outputApk);
outputFile.delete();
outputFile.createNewFile();
}
ZipFile zipInputFile = new ZipFile(inputFile);
zipOutputStream = new ZipOutputStream(new FileOutputStream(outputFile));
// ......
} finally {
if (zipOutputStream != null) {
zipOutputStream.close()
}
}
// ......
}
这个方法比较长,笔者把它拆开成几个部分来看
第一部分主要是做移除工作的预备工作
通过extension把参数导入进来:
- boolean needSign
- boolean shrinkArsc
- String apksignerPath
- Set
unusedResources - Set
ignoreResources
needSign和apksignerPath是用于判断是否能正常进行移除资源后的签名操作,不能就直接抛出异常
然后会传进来两个Set,分别是unusedResources和ignoreResources,代表无用资源的集合以及忽略的资源集合,然后便是从unusedResources中移除那些忽略的部分
所以这个Task并不会去找那些没有用到的资源,而是由外界传进来,然后Task只负责移除,提供了一定的动态性
然后构建一个shrinked.apk文件,然后做本地检查
RemoveUnusedResourcesTask-> removeUnusedResources
void removeUnusedResources(String originalApk, String rTxtFile, SigningConfig signingConfig) {
// ......
Map<String, Integer> resourceMap = new HashMap();
Map<String, Pair<String, Integer>[]> styleableMap = new HashMap();
File resTxtFile = new File(rTxtFile);
readResourceTxtFile(resTxtFile, resourceMap, styleableMap);
Map<String, Integer> removeResources = new HashMap<>();
for (String resName : unusedResources) {
if (!ignoreResource(resName)) {
removeResources.put(resName, resourceMap.remove(resName));
}
}
// ......
}
第二部分主要是做R.txt的解析以及数据结构的初始化
创建了resourceMap,针对R.txt中一般Res 如:string abc_action_bar_home_description 0x7f090000
创建了styleableMap,针对R.txt中styleable Res 如:int[] styleable TagLayout { 0x010100af, 0x7f0102b5, 0x7f0102b6 } 或者 int styleable TagLayout_android_gravity 0
然后只需要解析R.txt然后把内容填充到这两个map中就可以了
然后在resourceMap中过滤掉那些忽略的资源,转存到removeResources这个集合中
RemoveUnusedResourcesTask-> removeUnusedResources
void removeUnusedResources(String originalApk, String rTxtFile, SigningConfig signingConfig) {
// ......
for (ZipEntry zipEntry : zipInputFile.entries()) {
if (zipEntry.name.startsWith("res/")) {
String resourceName = entryToResouceName(zipEntry.name);
if (!Util.isNullOrNil(resourceName)) {
if (removeResources.containsKey(resourceName)) {
Log.i(TAG, "remove unused resource %s", resourceName);
continue;
} else {
addZipEntry(zipOutputStream, zipEntry, zipInputFile);
}
} else {
addZipEntry(zipOutputStream, zipEntry, zipInputFile);
}
} else {
if (needSign && zipEntry.name.startsWith("META-INF/")) {
continue;
} else {
if (shrinkArsc && zipEntry.name.equalsIgnoreCase("resources.arsc") && unusedResources.size() > 0) {
File srcArscFile = new File(inputFile.getParentFile().getAbsolutePath() + "/resources.arsc");
File destArscFile = new File(inputFile.getParentFile().getAbsolutePath() + "/resources_shrinked.arsc");
if (srcArscFile.exists()) {
srcArscFile.delete();
srcArscFile.createNewFile();
}
unzipEntry(zipInputFile, zipEntry, srcArscFile);
ArscReader reader = new ArscReader(srcArscFile.getAbsolutePath());
ResTable resTable = reader.readResourceTable();
for (String resName : removeResources.keySet()) {
ArscUtil.removeResource(resTable, removeResources.get(resName), resName);
}
ArscWriter writer = new ArscWriter(destArscFile.getAbsolutePath());
writer.writeResTable(resTable);
Log.i(TAG, "shrink resources.arsc size %f KB", (srcArscFile.length() - destArscFile.length()) / 1024.0);
addZipEntry(zipOutputStream, zipEntry, destArscFile);
} else {
addZipEntry(zipOutputStream, zipEntry, zipInputFile);
}
}
}
}
// ......
}
第三部分就是做实质的input.apk到_shrinked.apk的迁移以及resources.arsc文件的处理
这一步会遍历apk中的entry,然后先做名称转换,把entry name转换成R.xxx.xxxx的形式,目的就是为了和extension传进来的形式对应起来,然后对文件的处理比较简单,如果在map中,那就不写到_shrinked.apk中,如果不在,那就可以写了
然后关键的resources.arsc文件的处理来了:
- 用ArscReader把源resources.arsc文件读进来生成ResTable
- 用ArscUtil的removeResource方法去在ResTable中移除
- 最后用ArscWriter向目的resources.arsc文件把ResTable写进去
所以这位作者他的设计是用序列化和反序列化的方式实现resources.arsc文件的处理,把这个文件读到内存中构建一种数据结构,然后再内存中修改后再序列化回去生成一个新的问题,十分厉害!
这种做法难度会比较高,其实最开始我的想法只是修改aapt的源码然后在收集的时候剔除名单里的资源,然后再走一次编译过程,但是像Matrix这样做确实更好,不需要干涉编译的过程,而且维护还很方便
ArscUtil-> removeResource
public static void removeResource(ResTable resTable, int resourceId, String resourceName) throws IOException {
ResPackage resPackage = findResPackage(resTable, getPackageId(resourceId));
if (resPackage != null) {
List<ResType> resTypeList = findResType(resPackage, resourceId);
for (ResType resType : resTypeList) {
int entryId = getResourceEntryId(resourceId);
Log.i(TAG, "try to remove %s (%H), find resource %s", resourceName, resourceId, ArscUtil.resolveStringPoolEntry(resPackage.getResNamePool().getStrings().get(resType.getEntryTable().get(entryId).getStringPoolIndex()).array(), resPackage.getResNamePool().getCharSet()));
resType.getEntryTable().set(entryId, null);
resType.getEntryOffsets().set(entryId, ArscConstants.NO_ENTRY_INDEX);
resType.refresh();
}
resPackage.refresh();
resTable.refresh();
}
}
把resourceId解析成packageId,然后从ResTable中拿到指定的ResPackage
把resourceId解析成typeId,然后从ResPackage中拿到resType的集合
调用reolveStringPoolEntry方法把这个资源对应的资源名称列表拿到并且log出来
然后把resType中对应entry置为null,并且给予offset数组0xFFFF值标记这里没有索引
然后就是从内到外执行一次refresh操作
ArscUtil-> findResPackage
public static ResPackage findResPackage(ResTable resTable, int packageId) {
ResPackage resPackage = null;
for (ResPackage pkg : resTable.getPackages()) {
if (pkg.getId() == packageId) {
resPackage = pkg;
break;
}
}
return resPackage;
}
ArscUtil-> findResType
public static List<ResType> findResType(ResPackage resPacakge, int resourceId) {
ResType resType = null;
int typeId = (resourceId & 0X00FF0000) >> 16;
int entryId = resourceId & 0x0000FFFF;
List<ResType> resTypeList = new ArrayList<ResType>();
List<ResChunk> resTypeArray = resPacakge.getResTypeArray();
if (resTypeArray != null) {
for (int i = 0; i < resTypeArray.size(); i++) {
if (resTypeArray.get(i).getType() == ArscConstants.RES_TABLE_TYPE_TYPE
&& ((ResType) resTypeArray.get(i)).getId() == typeId) {
int entryCount = ((ResType) resTypeArray.get(i)).getEntryCount();
if (entryId < entryCount) {
int offset = ((ResType) resTypeArray.get(i)).getEntryOffsets().get(entryId);
if (offset != ArscConstants.NO_ENTRY_INDEX) {
resType = ((ResType) resTypeArray.get(i));
resTypeList.add(resType);
}
}
}
}
}
return resTypeList;
}
先从ResPackage中拿到一个typeArray,因为在package抽象出来的数据结构中是直接用ResChunk抽象typeSpec和type的,然后拿出来之后再转型
然后根据typeId匹配到ResType之后,然后就可以根据最后拆出来的entryId来查看ResType中是否有对应的ResEntry,有就把它添加到结果集中
这里会把所有ResType都返回了,毕竟这个时候没有Config能匹配,而且删除无用资源也肯定是要把所有ResType中都删掉的
ResType-> fresh
public void refresh() throws IOException {
//校正entryOffsets
int lastOffset = 0;
for (int i = 0; i < entryCount; i++) {
if (entryOffsets.get(i) != ArscConstants.NO_ENTRY_INDEX) {
entryOffsets.set(i, lastOffset);
lastOffset += entryTable.get(i).toBytes().length;
}
}
recomputeChunkSize();
}
结合fresh函数,回顾在removeResource方法中对ResType的处理:
resType.getEntryTable().set(entryId, null);
resType.getEntryOffsets().set(entryId, ArscConstants.NO_ENTRY_INDEX);
resType.refresh();
结合这两段代码就知道是如何keep住之前的id的同时又把无用的删去,做法就是:
- 对于无用资源,只把entry给它删掉,保留下来的entry的都是有用的
- 但是offset数组的数量和entryCount都不去动它
- 然后把通过重新设置偏移量就可以修正这个offset所指向的entry了
总结
所以其实Matrix中的RemoveUnusedResourcesTask的处理分两步走:
- 做一个新的apk,然后把原来apk的文件拷贝过去,其中把无用的资源文件过滤掉
- 把resources.arsc文件中与无用资源相关的entry删掉,然后给offset重定向
但是其实这套RemoveUnusedResources的方案并没有对常量池的内容进行修改,它把与无用资源相关的entry删掉了,但是为什么不顺便把常量池的内容也删掉呢?如果对常量池的修改用类似的偏移量处理不是也可以做到吗,为什么会忽略这一步呢?难道是有什么坑是未知的吗!?还是说是作者觉得这部分占的空间不多,就让它留着吧?
希望技术成长的以后能解决我今天的疑问!
参考资料: