本文的目的是实现抖音分享的二进制优化中关于layout文件的优化,具体见
抖音 Android 包体积优化探索:资源二进制格式的极致精简
优化思路
打包成apk后,layout二进制文件存在一些实际运行中不需要的字段,如namespace,属性名称等,那么是否可以去除呢?经过他们的验证测试,确认可以去掉,结论如下:
layout 优化:
属性字符串名称裁剪:可以实现,取得收益 400K+;
命名空间去除:可以实现,取得收益 200K+
实现思路
文章中仅提到了优化思路及结果,我们来根据思路把它实现了。
优化时机选择
参考aabresguard,可以复用它选择的时机;于是简化下,直接基于它二次开发,我们只需要关注资源优化即可。
参考源码片段
private fun createAabResGuardTask(project: Project, variant: ApplicationVariant) {
val variantName = variant.name.capitalize()
val bundleTaskName = "bundle$variantName"
if (project.tasks.findByName(bundleTaskName) == null) {
return
}
val aabResGuardTaskName = "aabresguard$variantName"
val aabResGuardTask: AabResGuardTask
aabResGuardTask = if (project.tasks.findByName(aabResGuardTaskName) == null) {
project.tasks.create(aabResGuardTaskName, AabResGuardTask::class.java)
} else {
project.tasks.getByName(aabResGuardTaskName) as AabResGuardTask
}
aabResGuardTask.setVariantScope(variant)
val bundleTask: Task = project.tasks.getByName(bundleTaskName)
val bundlePackageTask: Task = project.tasks.getByName("package${variantName}Bundle")
bundleTask.dependsOn(aabResGuardTask)
aabResGuardTask.dependsOn(bundlePackageTask)
// AGP-4.0.0-alpha07: use FinalizeBundleTask to sign bundle file
// FinalizeBundleTask is executed after PackageBundleTask
val finalizeBundleTaskName = "sign${variantName}Bundle"
val task = project.tasks.findByName(finalizeBundleTaskName)
if (task != null) {
aabResGuardTask.dependsOn(project.tasks.getByName(finalizeBundleTaskName))
}
}
优化逻辑
第一步,先熟悉AppBundle的格式,过滤我们需要处理的res资源类型:layout,drawable,anim等。
Map<BundleModuleName, BundleModule> bundleModules = new HashMap<>();
for (Map.Entry<BundleModuleName, BundleModule> entry : rawAppBundle.getModules().entrySet()) {
bundleModules.put(entry.getKey(), minifyModule(entry.getValue()));
}
private BundleModule minifyModule(BundleModule bundleModule){
List<ModuleEntry> list = new ArrayList<>();
for (ModuleEntry entry : bundleModule.getEntries()) {
ZipPath path = entry.getPath();
if (!path.startsWith(BundleModule.RESOURCES_DIRECTORY)) {
list.add(entry);
continue;
}
String toString = path.toString();
if (!toString.startsWith("res/") || !toString.endsWith(".xml")) {
list.add(entry);
continue;
}
boolean needMinify = toString.startsWith("res/drawable")
|| toString.startsWith("res/layout")
|| toString.startsWith("res/anim")
|| toString.startsWith("res/color");
if (needMinify) {
list.add(minifyLayout(entry));
continue;
}
list.add(entry);
}
return bundleModule.toBuilder()
.setRawEntries(list)
.build();
}
第二步,解析app bundle编译生成的目标文件,格式为protobuf
private ModuleEntry minifyLayout(ModuleEntry entry) {
try {
Resources.XmlNode raw = Resources.XmlNode.parseFrom(entry.getContent().openBufferedStream());
if (raw.hasElement()) {
Resources.XmlNode node = xmlMinifier.minify(raw);
byte[] bytes = node.toByteArray();
LogWriter.log("minify file:" + entry.getPath() + ", raw size=" + entry.getContent().size() + ", new size=" + bytes.length);
return entry.toBuilder().setContent(ByteSource.wrap(bytes)).build();
}
} catch (Exception e) {
String log = "minifyLayout error occur:" + entry.getPath();
System.out.println(log);
LogWriter.log(log);
}
return entry;
}
第三步,去掉运行时不需要的属性字段
class XmlMinifier {
private static String TAG = "XmlMinifier:";
public Resources.XmlNode minify(Resources.XmlNode node) {
if (node.hasElement()) {
Resources.XmlElement element = node.getElement();
Resources.XmlElement.Builder builder = element.toBuilder();
// minify XmlNamespace
int namespaceCount = element.getNamespaceDeclarationCount();
for (int i = 0; i < namespaceCount; i++) {
builder.setNamespaceDeclaration(
i,
minifyNameSpace(element.getNamespaceDeclaration(i))
);
}
// minify attribute
int attrCount = element.getAttributeCount();
for (int i = 0; i < attrCount; i++) {
Resources.XmlAttribute attr = element.getAttribute(i);
builder.setAttribute(i, minifyAttribute(attr));
}
// child
int childCount = element.getChildCount();
for (int i = 0; i < childCount; i++) {
if (element.getChild(i).hasElement()) {
builder.setChild(i, minify(element.getChild(i)));
}
}
return node.toBuilder().setElement(builder.build()).build();
} else {
LogWriter.log("ignore cause not an element");
return node;
}
}
private Resources.XmlNamespace minifyNameSpace(Resources.XmlNamespace space) {
return space.toBuilder().setPrefix("").setUri("").build();
}
private Resources.XmlAttribute minifyAttribute(Resources.XmlAttribute attr) {
boolean hasResId = attr.getResourceId() != 0;
boolean emptyNS = attr.getNamespaceUri().isEmpty();
boolean hasCompiledItem = attr.hasCompiledItem();
if (hasCompiledItem && hasResId) {
return attr.toBuilder().setNamespaceUri("").setName("").setValue("").build();
} else if (!emptyNS) {
return attr.toBuilder().setNamespaceUri("").build();
} else {
return attr;
}
}
}
根据参考文章,及测试结果,去掉namespace及属性名称,属性值,属性的namespace均不影响运行。
注意
- hasResId为false,这种情况主要为include的layout, fragment的name, style, class等;同时这些属性也是没有namespace的。
- hasCompiledItem为false,主要是使用原始值的属性,如text使用hard code,constraint_referenced_ids,layoutManager,onClick等。
最后一步,将这个优化流程插入到aabresguard的任务中,具体插入到混淆之前,可交由后续的混淆流程继续优化。
优化结果对比
经过优化apk减小1M多;高于抖音文章里的结果,一是更激进,不仅去掉了属性名称,也去掉了属性值,二是扩大了优化范围,不限于layout,连anim,drawable,color等res资源都一起做了优化。
- 优化前
- 优化后