包体积:Layout 二进制文件裁剪优化|得物技术
目录
一、引言
二、XML 解析流程
三、XML 二进制文件格式
1. StringPoolChunk
2. XmlStartElementChunk
3. ResourceMapChunk
4. NameSpaceChunk
四、XML 解析过程举例
五、工具介绍
1. API 介绍
2. StringPoolChunk 封装
3. ResourceMapChunk 封装
4. XmlStartElementChunk 封装
六、细节问题
1. Style 对应问题
2. 字节存储方式
七、裁剪优化实现
1. Namespace 移除
2. 属性名移除
3. Stringpool 偏移量修改
4. 效果对比
八、API 兼容调整
1. TabLayout 获取 Height 的场景
2. 图片库获取 SRC 的场景
3. DuToolbar 获取 Theme 的场景
九、收益
十、总结
一
引言
得物App在包体积优化方面已经进行了诸多尝试,收获也颇丰,已经集成的方案有图片压缩、重复资源删除、ARSC压缩等可移步至得物 Android 包体积资源优化实践。本文将主要介绍基于 XML 二进制文件的裁剪优化。
在正式进入裁剪优化前,需要先做准备工作,我们先从上层的代码看起,看看布局填充的方法。方便我们从始到终了解整个情况。
二
XML 解析流程
在 LayoutInflater 调用 Inflate 方法后,会将 XML 中的属性包装至 LayoutParams 中最后通过反射使用创建对应 View。
而在反射前,传入的 R.layout.xxx 文件是如何完成 XML 解析类的创建,后续又是如何通过该类完成 XML 中的数据解析呢?
上层 XML 解析最终会封装到 XmlBlock 这个类中。XmlBlock 封装了具体 RES 文件的解析数据。其中 nativeOpenXmlAsset 返回的就是 c 中对应的文件指针,后续取值都需要通过这个指针去操作。
XmlBlock 内部的 Parse 类实现了 XmlResourceParser ,最终被包装为 AttributeSet 接口返回。
例如调用 AttributeSet 的方法:
val attributeCount = attrs.attributeCount
for (i in 0 until attributeCount) {
val result = attrs.getAttributeValue(i)
val name = attrs.getAttributeName(i)
println("name:$name ,value::::$result")
}
//core/jni/android_util_XmlBlock.cpp
可以看到,我们最终都是通过 ResXmlParser 类传入对应的 ID 来完成取值。而不是通过具体的属性名称来进行取值。
上面介绍的是直接通过 Attrs 取值的方式,在实际开发中我们通常会使用 TypedArray 来进行相关属性值的获取。例如 FrameLayout 的创建工程。
public FrameLayout(@NonNull Context context, @Nullable AttributeSet attrs,
@AttrRes int defStyleAttr, @StyleRes int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
final TypedArray a = context.obtainStyledAttributes(
attrs, R.styleable.FrameLayout, defStyleAttr, defStyleRes);
saveAttributeDataForStyleable(context, R.styleable.FrameLayout,
attrs, a, defStyleAttr, defStyleRes);
if (a.getBoolean(R.styleable.FrameLayout_measureAllChildren, false)) {
setMeasureAllChildren(true);
}
a.recycle();
}
而 obtainStyledAttributes 方法最终会调用到 AssetManager 中的 applyStyle 方法,最终调用到 Native 的 nitiveApplyStyle 方法。
//https://android.googlesource.com/platform/frameworks/base/+/6d0e2c9cb948a10137e6b5a4eb00e601622fe8ee/core/jni/android_util_AssetManager.cpp
static jboolean android_content_AssetManager_applyStyle(JNIEnv* env, jobject clazz,
jlong themeToken,
jint defStyleAttr,
jint defStyleRes,
jlong xmlParserToken,
jintArray attrs,
jintArray outValues,
jintArray outIndices)
{
...
const jsize xmlAttrIdx = xmlAttrFinder.find(curIdent);
if (xmlAttrIdx != xmlAttrEnd) {
// We found the attribute we were looking for.
block = kXmlBlock;
xmlParser->getAttributeValue(xmlAttrIdx, &value);
DEBUG_STYLES(ALOGI("-> From XML: type=0x%x, data=0x%08x",
value.dataType, value.data));
}
...
}
//https://android.googlesource.com/platform/frameworks/base/+/6d0e2c9cb948a10137e6b5a4eb00e601622fe8ee/libs/androidfw/ResourceTypes.cpp
ssize_t ResXMLParser::getAttributeValue(size_t idx, Res_value* outValue) const
{
if (mEventCode == START_TAG) {
const ResXMLTree_attrExt* tag = (const ResXMLTree_attrExt*)mCurExt;
if (idx < dtohs(tag->attributeCount)) {
const ResXMLTree_attribute* attr = (const ResXMLTree_attribute*)
(((const uint8_t*)tag)
+ dtohs(tag->attributeStart)
+ (dtohs(tag->attributeSize)*idx));
outValue->copyFrom_dtoh(attr->typedValue);
if (mTree.mDynamicRefTable != NULL &&
mTree.mDynamicRefTable->lookupResourceValue(outValue) != NO_ERROR) {
return BAD_TYPE;
}
return sizeof(Res_value);
}
}
return BAD_TYPE;
}
三
XML 二进制文件格式
你写的代码是这个样子,App 打包过程中通过 AAPT2 工具处理完 XML文件,转换位二进制文件后就是这个样子。
要了解这个二进制文件,使用 命令行 hexdump 查看:
在二进制文件中,不同数据类型分块存储,共同组成一个完整文件。我们可以通过依次读取每个字节,来获取对应的信息。要准确读取信息,就必须清楚它的定义规则和顺序,确保可以正确读取出内容。
https://android.googlesource.com/platform/frameworks/base/+/refs/heads/master/libs/androidfw/include/androidfw/ResourceTypes.h
每一块(Chunk)都按固定格式生成,最基础的定义有:
Type:类型 分类,对应上面截图中的类型
headerSize:头信息大小
Size:总大小 (headerSize+dataSize)通过这个值,你可以跳过该 Chunk 的内容,如果 Size 和 headerSize 一致,说明该 Chunk 没有数据内容。
StringPoolChunk
所有的 Chunk 中,都包含 ResChunk ,作为基础信息。这里以 StringPoolChunk 举例:
在 StringPool 中,除了基础的 ResChunk ,还额外包含以下信息:
stringCount: 字符串常量池的总数量
styleCount: style 相关的的总数量
Flag: UTF_8 或者 UTF_16 的标志位 我们这里默认就是 UTF_8
stringsStart:字符串开始的位置
stylesStart:styles 开始的位置
字符串从 stringStart 的位置相对开始,两个字节来表示长度,最后以 0 结束。
XmlStartElementChunk
startElementChunk 是布局 XML 中核心的标签封装对象,里面记录了Namespace ,Name,Attribute 及相关的 Index 信息,其中 Attribute 中有用自己的 Name Value等具体封装。
ResourceMapChunk
ResourceMapChunk是一个 32 位的 Int 数组,在我们编写的 XML 中没有直观体现,但是在编译为二进制文件后,它的确存在,也是我们后续能执行裁剪属性名的重要依据:它与 String Pool 中的资源定义相匹配。
NameSpaceChunk
NameSpaceChunk 就是对 Namespace 的封装,主要包含了前缀(Android Tools App),和具体的 URL。
ResourceType.h 文件中定义了所以需要使用的类型,也是面向对象的封装形式。后面讲解析时,也会根据每种数据类型进行具体的解析处理。
四
XML 解析过程举例
我们以获取 StringPool 的场景来举例二进制文件的解析过程,通过这个过程,可以掌握字节读取的具体实现。解析过程其实就是从 0 开始的字节偏移量获取。每次读取多少字节,依赖前面 ResourceTypes.h 中的格式定义。
第一行
00000000 03 00 08 00 54 02 00 00 01 00 1c 00 e4 00 00 00 |....T...........| 00 03 XML 类型 00 08 header size 54 02 00 00 Chunksize (0254 596) 00 01 : StringPool 00 1c headersize (28) 00 00 00 e4 :Chunksize (228)
第二行
00000010 0b 00 00 00 00 00 00 00 00 01 00 00 48 00 00 00 |............H...| 00 00 00 0b : stringCount (getInt) 11 00 00 00 00 : styleCount (getInt) 0 00 00 01 00 : flags (getInt) 1 使用 UTF-8 00 00 00 48 : StringStart (getInt) 72
第三行
00000020 00 00 00 00 00(indx 36) 00 00 00 0b 00 00 00 17 00 00 00 |................| 00 00 00 00 : styleStart(getInt) 0 (StringPoolChunk 中最后一个字段获取) 00(index 36) 00 00 00 : readStrings 第一次偏移 0 (72 + 8 从 index 80 开始)
0b 00 00 00: readStrings 第二次偏移 11 (80+11 从 91 开始)
00 00 00 17:readString 第三次偏移 23 (80 +23 从 103 开始)
第四行
00000030 1c 00 00 00 2b 00 00 00 3b 00 00 00 42 00 00 00 |....+...;...B...|
00 00 00 1c:readString 第四次偏移 28 (80+28 从 108 开始)
00 00 00 2b:readString 第五次偏移 43
第六行
00000050 08(index 80) 08 74 65 78 74 53 69 7a 65 00 09(index 91) 09 74 65 78 |..textSize...tex|
第七行
00000060 74 43 6f 6c 6f 72 00 02(index 103) 02 69 64 00 0c(index 108) 0c 6c 61 |tColor...id...la|
第八行
00000070 79 6f 75 74 5f 77 69 64 74 68 00 0d 0d 6c 61 79 |yout_width...lay|
五
工具介绍
通过上面的手动解析二进制文件字节信息,既然格式如此固定,那多半已经有人做过相关封装解析类吧,请看JakeWharton:https://github.com/madisp/android-chunk-utils
API 介绍
StringPoolChunk 封装
String 按之前手动解析的思路,是通过偏移量获取 String 的开始位置及具体长度,完成不同 String 的读取。
protected Chunk(ByteBuffer buffer, @Nullable Chunk parent) {
this.parent = parent;
offset = buffer.position() - 2;
headerSize = (buffer.getShort() & 0xFFFF);
chunkSize = buffer.getInt();
}
//StringPoolChunk
protected StringPoolChunk(ByteBuffer buffer, @Nullable Chunk parent) {
super(buffer, parent);
stringCount = buffer.getInt();
styleCount = buffer.getInt();
flags = buffer.getInt();
stringsStart = buffer.getInt();
stylesStart = buffer.getInt();
}
// StringPoolChunk
@Override
protected void init(ByteBuffer buffer) {
super.init(buffer);
strings.addAll(readStrings(buffer, offset + stringsStart, stringCount));
styles.addAll(readStyles(buffer, offset + stylesStart, styleCount));
}
private List<String> readStrings(ByteBuffer buffer, int offset, int count) {
List<String> result = new ArrayList<>();
int previousOffset = -1;
// After the header, we now have an array of offsets for the strings in this pool.
for (int i = 0; i < count; ++i) {
int stringOffset = offset + buffer.getInt();
result.add(ResourceString.decodeString(buffer, stringOffset, getStringType()));
if (stringOffset <= previousOffset) {
isOriginalDeduped = true;
}
previousOffset = stringOffset;
}
return result;
}
public static String decodeString(ByteBuffer buffer, int offset, Type type) {
int length;
int characterCount = decodeLength(buffer, offset, type);
offset += computeLengthOffset(characterCount, type);
// UTF-8 strings have 2 lengths: the number of characters, and then the encoding length.
// UTF-16 strings, however, only have 1 length: the number of characters.
if (type == Type.UTF8) {
length = decodeLength(buffer, offset, type);
offset += computeLengthOffset(length, type);
} else {
length = characterCount * 2;
}
return new String(buffer.array(), offset, length, type.charset());
}
ResourceMapChunk 封装
资源 ID 对比 String 显得更加简单,因为它的长度固定为的 32 位 4 字节,所以用 dataSize 除以 4 就可以得到ResourceMap 的大小,然后依次调用 buffer.getInt() 方法获取即可。
ResourceMap封装过程:
private List<Integer> enumerateResources(ByteBuffer buffer) {
// id 固定为 4 个字节
int resourceCount = (getOriginalChunkSize() - getHeaderSize()) / RESOURCE_SIZE;
List<Integer> result = new ArrayList<>(resourceCount);
int offset = this.offset + getHeaderSize();
buffer.mark();
buffer.position(offset);
for (int i = 0; i < resourceCount; ++i) {
result.add(buffer.getInt());
}
buffer.reset();
return result;
}
XmlStartElementChunk 封装
protected XmlStartElementChunk(ByteBuffer buffer, @Nullable Chunk parent) {
super(buffer, parent);
// 获取namespace的id
namespace = buffer.getInt();
// 获取名称
name = buffer.getInt();
// 获取属性索引的开始位置
attributeStart = (buffer.getShort() & 0xFFFF);
// 获取索引的总大小
int attributeSize = (buffer.getShort() & 0xFFFF);
// 强制检查 attributeSize 的值是否为固定值,
Preconditions.checkState(attributeSize == XmlAttribute.SIZE, // 20
"attributeSize is wrong size. Got %s, want %s", attributeSize, XmlAttribute.SIZE);
attributeCount = (buffer.getShort() & 0xFFFF);
// The following indices are 1-based and need to be adjusted.
idIndex = (buffer.getShort() & 0xFFFF) - 1;
classIndex = (buffer.getShort() & 0xFFFF) - 1;
styleIndex = (buffer.getShort() & 0xFFFF) - 1;
}
private List<XmlAttribute> enumerateAttributes(ByteBuffer buffer) {
List<XmlAttribute> result = new ArrayList<>(attributeCount);
int offset = this.offset + getHeaderSize() + attributeStart;
int endOffset = offset + XmlAttribute.SIZE * attributeCount;
buffer.mark();
buffer.position(offset);
while (offset < endOffset) {
result.add(XmlAttribute.create(buffer, this));
offset += XmlAttribute.SIZE;
}
buffer.reset();
return result;
}
/**
* Creates a new {@link XmlAttribute} based on the bytes at the current {@code buffer} position.
*
* @param buffer A buffer whose position is at the start of a {@link XmlAttribute}.
* @param parent The parent chunk that contains this attribute; used for string lookups.
*/
public static XmlAttribute create(ByteBuffer buffer, XmlNodeChunk parent) {
int namespace = buffer.getInt(); // 4
int name = buffer.getInt(); // 4
int rawValue = buffer.getInt(); // 4
ResourceValue typedValue = ResourceValue.create(buffer);
return new AutoValue_XmlAttribute(namespace, name, rawValue, typedValue, parent);
}
public static ResourceValue create(ByteBuffer buffer) {
int size = (buffer.getShort() & 0xFFFF); //2
buffer.get(); // Unused // Always set to 0. 1
Type type = Type.fromCode(buffer.get());//1
int data = buffer.getInt(); // 4
return new AutoValue_ResourceValue(size, type, data);
}
六
细节问题
Style 对应问题
在 StringPool 中我们可以看到除了 String 的定义,这里还有 Style 的定义,那么这个 Style 到底对应什么呢?经过一番测试后,Style 其实对应 Strings.xml 中定义的富文本内容,例如:
<string name="spannable_string">
This is a <b>bold</b> text, this is an <i>italic</i>
</string>
这种内容,最后在 解析 Arsc 文件时,就会有有 Style 相关的属性。
我们注意主要聚焦于 Layout 文件,所以这里不再展开分析。
字节存储方式
Little Endian:低位字节序
Big Endian:高位字节序
在 Little Endian 字节序中,数据的最低有效字节存储在内存地址的最低位置,而最高有效字节则存储在内存地址的最高位置。这种字节序的优点是可以更好地利用内存,能够更容易地处理低位字节和高位字节的组合,尤其是在处理较大的整数和浮点数时比较快速。
在 Big Endian 字节序中,数据的最高有效字节存储在内存地址的最低位置,而最低有效字节则存储在内存地址的最高位置。这种字节序虽然更符合人类读写的方式,但在高效率方面却不如 Little Endian 字节序。
举例 0x12345678
低位存储
高位存储
在 Java 中,默认采用 Big Endian 存储方式,所以我们修改二进制文件时,需要手动指定为低位字节序。
七
裁剪优化实现
Namespace 移除
将字符串池中命名空间字符串替换成""空串,以达到减少文件内容的效果。有上面 NameSpaceChunk 中的介绍可知,nameSpace 的前缀及具体 URL 都存放在 StringPool 中,具体内容(字节,偏移量)封装在 NameSpaceChunk 中。所以需要执行两个步骤:1. 移除 NameSpaceChunk 对象 2. 置空 StringPool 中的字符串内容。
第一步使用 Android-Chunk-Utils 代码如下,第二步和属性名移除并列执行。
FileInputStream(resourcesFile).use { inputStream ->
val resouce = ResourceFile.fromInputStream(inputStream)
val chunks = sChunk.chunks
// 过滤出所有的 NameSpaceChunk 对象
val result = chunks.values.filter { it is XmlNamespaceChunk }
// 移除
chunks.values.removeAll(result.toSet())
}
属性名移除
将字符串池中每一个字符串替换成""空字符串。
StringPoolChunk 中记录了 XML 中的所有组件名称及其属性,而每个属性对应的具体 ID ,则是固定的,在ResourceMapChunk 中,由 Index 一一对应。
举个例子,在这个布局文件中, Layout_width 的在 StringPool 中的索引是 6 ,对应在 ResourceMapChunk 中是 16842996 的值,转换十六进制后:10100f4,与 public.xml 中定义的属性 ID 完全对应。
通过上面源码的介绍,每个属性(Attr)包含一个对应的整型 ID 值,获取其属性值时都会通过该 ID 值来获取。所以对应的属性名理论上可以移除。具体代码如下:
private fun handleStringPoolValue(strings: MutableList<String>, resources: MutableList<Int>?, stringPoolChunk: StringPoolChunk, emptyIndexs: MutableList<Int>) {
strings.forEachIndexed { i, k ->
val res = resources
// 默认属性置空
if (res != null && i < res.size) {
stringPoolChunk.setString(i, "")
emptyIndexs.add(i)
}
// 命名空间置空
else if (k == "http://schemas.android.com/apk/res/android") {
stringPoolChunk.setString(i, "")
emptyIndexs.add(i)
} else if (k == "http://schemas.android.com/apk/res-auto") {
stringPoolChunk.setString(i, "")
emptyIndexs.add(i)
} else if (k == "http://schemas.android.com/tools") {
stringPoolChunk.setString(i, "")
emptyIndexs.add(i)
} else if (k == "android") {
stringPoolChunk.setString(i, "")
emptyIndexs.add(i)
} else if (k == "app") {
stringPoolChunk.setString(i, "")
emptyIndexs.add(i)
} else if (k == "tools") {
stringPoolChunk.setString(i, "")
emptyIndexs.add(i)
}
}
}
Stringpool 偏移量修改
经过上面两个步骤后,StringPool 已经得到优化,但是观察新的二进制文件会发现,目前总大小为:00 00 01 c4 (452) ,优化前为 00 00 02 54(596)。经过进一步分析可以发现,StringPool 中字符串的偏移量还可以优化。
第二行
00000010 0b 00 00 00 00 00 00 00 00 01 00 00 48 00 00 00 |............H...|
第三行
00000020 00 00 00 00 00(index 36) 00 00 00 03 00 00 00 06 00 00 00 |................|
第四行
00000030 09 00 00 00 0c 00 00 00 0f 00 00 00 12 00 00 00 |................|
第六行
00000050 00(index 80) 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................|
可以看到空字符串第一个偏移量是 0: 72+8 80 ,从 80 开始,每一个空字符串都由一组 00 00 00 来表示,这里也会有冗余的存储占用。那这里是否可以控制偏移量,就用一组 00 00 00 来表示呢?答案是可以的, Android-Chunk-Utils 工具类已经给我们提供了策略支持。ResourceFile.toByteArray 回写方法就提供了 Shrink 参数。
FileOutputStream(resourcesFile).use {
it.write(newResouce.toByteArray(true))
}
@Override
public byte[] toByteArray(boolean shrink) throws IOException {
ByteArrayDataOutput output = ByteStreams.newDataOutput();
for (Chunk chunk : chunks) {
output.write(chunk.toByteArray(shrink));
}
return output.toByteArray();
}
//StringPoolChunk 中的具体写入实现
@Override
protected void writePayload(DataOutput output, ByteBuffer header, boolean shrink)
throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
int stringOffset = 0;
ByteBuffer offsets = ByteBuffer.allocate(getOffsetSize());
offsets.order(ByteOrder.LITTLE_ENDIAN);
// Write to a temporary payload so we can rearrange this and put the offsets first
try (LittleEndianDataOutputStream payload = new LittleEndianDataOutputStream(baos)) {
stringOffset = writeStrings(payload, offsets, shrink);
writeStyles(payload, offsets, shrink);
}
output.write(offsets.array());
output.write(baos.toByteArray());
if (!styles.isEmpty()) {
header.putInt(STYLE_START_OFFSET, getHeaderSize() + getOffsetSize() + stringOffset);
}
}
private int writeStrings(DataOutput payload, ByteBuffer offsets, boolean shrink)
throws IOException {
int stringOffset = 0;
Map<String, Integer> used = new HashMap<>(); // Keeps track of strings already written
for (String string : strings) {
// Dedupe everything except stylized strings, unless shrink is true (then dedupe everything)
if (used.containsKey(string) && (shrink || isOriginalDeduped)) {
// 如果支持优化,将复用之前的数据和 offest
Integer offset = used.get(string);
offsets.putInt(offset == null ? 0 : offset);
} else {
byte[] encodedString = ResourceString.encodeString(string, getStringType());
payload.write(encodedString);
used.put(string, stringOffset);
offsets.putInt(stringOffset);
stringOffset += encodedString.length;
}
}
经过三步优化,重新更新 XML 文件后再次确定二进制信息,获取 Chunck 的总大小为:00 00 01 b0 (432),对比原始 XML 文件,一共减少 164 (28% )。当然这个减少数据量取决于 XML 中标签及属性的数量,越复杂的 XML 文件,缩减率越高。
效果对比
裁剪前
八
API 兼容调整
虽然理论上说移除布局的属性后对于正常的流程无影响,但是,该有的问题总还是会有的,真一个问题都没有那才让人心里不踏实,接下来看兼容的一些异常情况。
TabLayout 获取 Height 的场景
这个写法同时使用了 Namespace 和 特定属性,布局初始化时直接就会 Crash 。后面扫描了所有使用 getAttributeValue 方法的类,筛选确定后进行统一代码调整。
int[] systemAttrs = {android.R.attr.layout_height};
TypedArray a = context.obtainStyledAttributes(attrs, systemAttrs);
try {
// 如果定义的是 WRAP_CONTENT 或者 MATCH_PARENT 这里会异常,然后通过 getInt 获取值(-1 -2)
mHeight = a.getDimensionPixelSize(0, ViewGroup.LayoutParams.WRAP_CONTENT);
} catch (Exception e) {
// e.printStackTrace();
mHeight = a.getInt(0, ViewGroup.LayoutParams.WRAP_CONTENT);
}
图片库获取 SRC 的场景
图片库内部默认支持了 ImageView 的 SRC 属性,具体获取方式使用了 getAttributeResourceValue 的方法。
因为图片库调用的地方做了默认 Catch 捕获异常,所以 App 没有 Crash ,但是对于使用 SRC 属性设置的图片资源无法正常显示。
后续调整为:
try {
val a = context.obtainStyledAttributes(attrs, intArrayOf(android.R.attr.src))
if (a.hasValue(0)) {
val drawable = a.getDrawable(0)
load(drawable)
}
a.recycle()
} catch (e: Exception) {
e.printStackTrace()
}
DuToolbar 获取 Theme 的场景
我们的 Toolbar 有统一拦截设置,其中支持根据页面设置是黑色或者白色的返回按钮样式,而这个设置就是通过 XML 中 Theme 主题关联。这是之前的判断,可以看到直接使用属性名来判断。由于属性移除,该判断条件永远执行不到,导致 Toolbar 返回按钮在特定页面未按预期颜色展示。
调整为:
if (attrs != null) {
TypedArray a = context.obtainStyledAttributes(attrs, new int[]{android.R.attr.theme});
if (a.hasValue(0)) {
int[] attr = new int[]{R.attr.colorControlNormal, android.R.attr.textColorPrimary};
TypedArray array = context.obtainStyledAttributes(attrs.getAttributeResourceValue(0, 0), attr);
try {
mNavigationIconTintColor = array.getColor(0, Color.BLACK);
mTitleTextColor = array.getColor(1, Color.BLACK);
} finally {
array.recycle();
}
}
a.recycle();
}
修改后发现依然有问题,对比异常的不同页面发现以下区别:
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
app:layout_collapseMode="pin"
app:popupTheme="@style/ThemeToolbarWhite"
app:theme="@style/ThemeToolbarWhite">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:theme="@style/ThemeToolbarWhite"
app:popupTheme="@style/ThemeOverlay.AppCompat.Light"
app:title="领取数字藏品"
app:titleTextColor="@android:color/white" />
使用 Android 命名空间生成的属性是系统自带属性,定义在 public.xml 中,使用 App 生成的属性是自定义属性,打包到 Arsc 的 Attr 中。
所以,上面仅判断 Android.R.attr.theme 不够,还需要增加 R.attr.theme 。
TypedArray a = context.obtainStyledAttributes(attrs, new int[]{R.attr.theme, android.R.attr.theme});
九
收益
最后,确定下总体包体积优化收益:
移除 Namespace 及属性值后:
优化空字符串后:
由于这是 Apk 解压后的所有文件汇总收益,重新压缩打包 Apk 后,包体积整体收益在 2.2 M左右。
十
总结
本文介绍了得物App的包体积优化工作,讲解了针对XML二进制文件的裁剪优化。文章首先概述了XML解析流程和XML二进制文件格式,然后介绍了解析过程中的一些工具以及细节问题,探讨了裁剪优化实现以及API的兼容调整,最后呈现了包体积优化的收益。
参考文档:
https://blog.csdn.net/ByteDanceTech/article/details/124642609
往期回顾
关注得物技术,每周一、三、五更新技术干货
要是觉得文章对你有帮助的话,欢迎评论转发点赞~
未经得物技术许可严禁转载,否则依法追究法律责任。
“
扫码添加小助手微信
如有任何疑问,或想要了解更多技术资讯,请添加小助手微信: