背景
随着业务的快速迭代增长,京东主站APP不断加入新的代码、图片资源、第三方SDK、React Native等等,直接导致APK体积不断增加。APK体积增长也会带来诸多问题,例如:推广费用增加,用户下载意愿降低,流量费用增加,下载及安装成功率降低,甚至可能会影响用户留存率;应用市场限制,Google Play规定安装包上限为100M。所以APK瘦身已经是迫在眉睫的事情。在尝试瘦身过程中,我们借鉴了很多业界其他同行的方案,比如资源混淆、图片压缩/转码等,同时针对自己需求发现了一些新的技巧。本文主要讲解如何使用Python对APK进行分析,统计基础数据,分析可优化的资源,为应用瘦身提供数据支持。
分析APK的前提条件就是要充分了解APK组成,所以下文将首先简单介绍APK组成。
APK文件目录
APK是一个压缩包,使用aapt l file.apk命令可以查看APK下所有文件,如下:
简单归类如下 :
当然还会有一些其他文件,例如org/,src/,aidl等等文件或文件夹,这些资源是Java Resources,具体详情可以了解下APK打包流程。
在充分了解APK组成部分后,下面来介绍下APK扫描实现主要工作。
APK分析主要工作
分析APK主要分为以下部分:
1)下载APK以及mapping文件。
2)AAPT获取APK信息。
3)读取APK在操作系统中大小(apk_file_size)以及APK真正大小(apk_download_size)。
4)还原混淆后资源ID。
5)根据文件MD5判断重复资源文件。
6)读取DEX头文件获取classes.dex的class_numbers和references_methods。
7)获取非alpha通道图以及图片尺寸,遍历出非透明通道图。
8)ZipFile解压APK的so文件并读取so文件内容,还原so混淆的资源ID,so中非透明通道图。
9)res/下无用资源。
以上工作主要使用Python进行实现,Python断点续传下载APK以及Mapping文件之后解压文件,为之后分析APK做好准备。这里关于Python下载实现不再赘述。
通过AAPT命令可以获取APK的package_name,version_name,version_code,launch_activity,min_sdk_version,target_sdk_version,application_label等信息
具体实现如下:
def get_apk_base_info(self):
# 获取apk包的基本信息
p = subprocess.Popen(self.aapt_path + " dump badging %s" % self.apkPath, stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
stdin=subprocess.PIPE, shell=True)
(output, err) = p.communicate()
package_match = re.compile("package: name='(\S+)' versionCode='(\d+)' versionName='(\S+)'").match(output.decode())
if not package_match:
raise Exception("can't get package,versioncode,version")
package_name = package_match.group(1)
version_code = package_match.group(2)
version_name = package_match.group(3)
launch_activity_match = re.compile("launchable-activity: name='(\S+)'").search(output.decode())
if not launch_activity_match:
raise Exception("can't get launch_activity")
launch_activity = launch_activity_match.group(1)
sdk_version_match = re.compile("sdkVersion:'(\S+)'").search(output.decode())
if not sdk_version_match:
raise Exception("can't get min_sdk_version")
min_sdk_version = sdk_version_match.group(1)
target_sdk_version_match = re.compile("targetSdkVersion:'(\S+)'").search(output.decode())
if not target_sdk_version_match:
raise Exception("can't get target_sdk_version")
target_sdk_version = target_sdk_version_match.group(1)
application_label_match = re.compile("application-label:'([\u4e00-\u9fa5_a-zA-Z0-9-\S]+)'").search(output.decode())
if not application_label_match:
raise Exception("can't get application_label")
application_label = application_label_match.group(1)
return package_name, version_name, version_code,launch_activity,min_sdk_version,target_sdk_version,application_label
3.2 apk_file_size & apk_download_size
apk_file_size是APK在操作系统中占据存储空间,可以通过os模块直接获取;apk_download_size是APK内实际大小,可以ZipFile获取每个文件压缩大小,实现如下:
def get_apk_size(self):
# 得到apk的文件大小
size = round(os.path.getsize(self.apkPath) / (1024 * 1000), 2)
# return str(size) + "M"
return os.path.getsize(self.apkPath)
def get_apk_download_size(apk_file_name):
# 获取apk_download_size
zip_file = zipfile.ZipFile(apk_file_name, 'r')
zip_infos = zip_file.infolist()
download_size = 0
for index in range(len(zip_infos)):
zip_info = zip_infos[index]
download_size += zip_info.compress_size
return download_size
许多人多使用apktool.jar解压APK,然后遍历APK文件夹,该方法可以解决除apk_download_size大部分功能,介于以上获取apk_download_size使用ZipFile读取APK文件,这里同样采用ZipFile读取APK内容。且将APK文件作为压缩文件直接使用ZipFile进行读取压缩文件内容,该方法可以免去解压APK流程,一定程度上提高遍历速度。
def __get_files_from_apk(apk_file_name, apk_name_without_suffix, mapping_name_without_suffix):
# 读取混淆文件
proguard_map = reproguard.read_proguard_apk(mapping_name_without_suffix)
zip_file = zipfile.ZipFile(apk_file_name, 'r')
# 获取APK文件内所有文件列表
file_name_list = zip_file.namelist()
# 遍历APK文件下文件列表
for index in range(len(file_name_list)):
file_name = str(file_name_list[index])
# 还原混淆文件
if proguard_map:
entry_name = str(reproguard.replace_path_id(file_name, proguard_map)) if("/" in file_name) else file_name
else:
entry_name = file_name
# 获取文件MD5值
md5_str = md5.get_md5_value(file_name)
parent_dir = entry_name[:parent_index] if parent_index >= 0 else ""
# 根据文件名获取文件的ZipInfo(压缩文件)
zip_info = zip_file.getinfo(file_name)
file_info = FileInfo(
path=file_name,
entry_name=entry_name,
md5_str=md5_str,
compress_size=zip_info.compress_size,
file_type=file_type,
zip_file=zip_info
)
if "so" == file_type and "libcom.jd.lib" in entry_name:
# aura插件分析
......
elif "assets/jdreact/" in entry_name:
# React Native分析
......
elif "dex" == file_type:
# dex分析方法类,获取APK中class数+references methods数
......
elif file_util.is_image(entry_name):
# 如果是图片文件,就要分析图片的尺寸,以及判断是否是 非透明通道图
.....
zip_file.close()
return apk_file_list, aura_bundles, dex_files, react_modules
以上内容可以获取APK中大部分内容,但是要获取APK中涉及的class数目,以及methods数目,显然以上分析均不能满足条件。这也是需要了解DEX结构并分析DEX文件的原因所在。DEX文件作为Android APK的组成部分,是Android的Java代码经过编译生成class文件,在经过dx命令生成的,它包含了APK的源码,反编译时最主要就是对这个文件进行反编译。首先简单了解下DEX文件格式。
DEX格式:
名称 | 格式 | 说明 |
header | header_item | DEX文件头部,记录整个DEX文件的相关属性 |
string_ids | string_id_item[] | 字符串数据索引.记录了每个字符串在数据区的偏移量 |
type_ids | type_id_item[] | 类型数据索引.是DEX文件引用的所有类型(类,数组或原始类型)的字符串索引 |
proto_ids | proto_id_item[] | 方法原型索引.记录了方法声明的字符串(指向string_ids),返回类型(指向type_ids),参数列表(指向typeList) |
field_ids | field_id_item[] | 字段数据索引.是DEX文件引用的所有字段索引,记录了所属类,字段类型(指向type_ids)和字段名(指向string_ids) |
method_ids | method_id_item[] | 方法索引.记录了所属类名,定义类型(指向type_ids),方法名称(指向string_ids),方法原型(指向proto_ids) |
class_defs | class_def_item[] | 类定义数据索引,记录了指定类各类信息,包括8各部分,类的类型,访问标志,父类类型,实现接口,源文件,注解,class_data |
method_handles | method_handle_item[] | 方法句柄列表 |
data | ubyte[] | 数据区,上面所有表格的支持数据 |
link_data | ubyte[] | 静态链接文件中使用数据 |
从DEX文件格式中我们看到有string、field、class、method等标识符列表,唯独没有我们想要了解的class、methods、field、string数量及其所在偏移量。这时我们看到一个header组成部分,经了解header组成如下:
字段名称 | 偏移值 | 长度 | 说明 |
magic | 0x0 | 8 | 魔数字段,值为"DEX\n035\0" |
checksum | 0x8 | 4 | 校验码 |
signature | 0xc | 20 | sha-1签名 |
file_size | 0x20 | 4 | DEX文件总长度 |
header_size | 0x24 | 4 | 文件头长度,009版本=0x5c,035版本=0x70 |
endian_tag | 0x28 | 4 | 标示字节顺序的常量 |
link_size | 0x2c | 4 | 链接段的大小,如果为0就是静态链接 |
link_off | 0x30 | 4 | 链接段的开始位置 |
map_off | 0x34 | 4 | map数据基址 |
string_ids_size | 0x38 | 4 | 字符串列表中字符串个数 |
string_ids_off | 0x3c | 4 | 字符串列表基址 |
type_ids_size | 0x40 | 4 | 类列表里的类型个数 |
type_ids_off | 0x44 | 4 | 类列表基址 |
proto_ids_size | 0x48 | 4 | 原型列表里面的原型个数 |
proto_ids_off | 0x4c | 4 | 原型列表基址 |
field_ids_size | 0x50 | 4 | 字段个数 |
field_ids_off | 0x54 | 4 | 字段列表基址 |
method_ids_size | 0x58 | 4 | 方法个数 |
method_ids_off | 0x5c | 4 | 方法列表基址 |
class_defs_size | 0x60 | 4 | 类定义标中类的个数 |
class_defs_off | 0x64 | 4 | 类定义列表基址 |
data_size | 0x68 | 4 | 数据段的大小,必须4k对齐 |
data_off | 0x6c | 4 | 数据段基址 |
其中有method_ids_size和class_defs_size,这两个数据就是所需要得到class数量和references methods数量,另外还有一些其他string_ids数目以及偏移量,type_ids数量以及偏移量等等。
所以读取DEX header即可得到DEX中定义的class方法数以及引用方法数。Android 虚拟机也是通过引用方法数(references methods)进行分包。
references methods包括第三方引用方法+自定义方法数。可以作为分析DEX的标准,具体实现如下:
def ReadDexHeader_(self, file_dir):
# 以二进制形式读取文件
f = open(file_dir, 'rb')
m = mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ)
self.mmap = m
....省略DEX部分内容读取
string_ids_size = struct.unpack('<L', m[0x38:0x3C])[0]
string_ids_off = struct.unpack('<L', m[0x3C:0x40])[0]
type_ids_size = struct.unpack('<L', m[0x40:0x44])[0]
type_ids_off = struct.unpack('<L', m[0x44:0x48])[0]
proto_ids_size = struct.unpack('<L', m[0x48:0x4C])[0]
proto_ids_off = struct.unpack('<L', m[0x4C:0x50])[0]
field_ids_size = struct.unpack('<L', m[0x50:0x54])[0]
field_ids_off = struct.unpack('<L', m[0x54:0x58])[0]
method_ids_size = struct.unpack('<L', m[0x58:0x5C])[0]
method_ids_off = struct.unpack('<L', m[0x5C:0x60])[0]
class_defs_size = struct.unpack('<L', m[0x60:0x64])[0]
class_defs_off = struct.unpack('<L', m[0x64:0x68])[0]
data_size = struct.unpack('<L', m[0x68:0x6C])[0]
data_off = struct.unpack('<L', m[0x6C:0x70])[0]
header_data = dict({})
header_data['string_ids_size'] = string_ids_size
header_data['string_ids_off'] = string_ids_off
header_data['type_ids_size'] = type_ids_size
header_data['type_ids_off'] = type_ids_off
header_data['proto_ids_size'] = proto_ids_size
header_data['proto_ids_off'] = proto_ids_off
header_data['field_ids_size'] = field_ids_size
header_data['field_ids_off'] = field_ids_off
header_data['method_ids_size'] = method_ids_size
header_data['method_ids_off'] = method_ids_off
header_data['class_defs_size'] = class_defs_size
header_data['class_defs_off'] = class_defs_off
header_data['data_size'] = data_size
header_data['data_off'] = data_off
self.header = header_data
3.5获取非alpha通道图以及图片尺寸
在计算机图形学中, 每一张图片都是由一个或多个数据通道构成。例如:RGB图像就是有3个通道构成,分别为R、G、B。而透明通道在一定程度上增强视觉感染力。本文的非alpha通道图是图片大小>10kb & 非.9.png&没有alpha通道的PNG图。该过程可以在一定程度上避免使用大图。
from PIL import Image
try:
image_bytes = io.BytesIO(zip_file.read(file_name))
img = Image.open(image_bytes)
# print(img.format) # JPEG
# 图片尺寸,单位像素
image_size = img.size # (801, 1200)
filename_without_suffix, image_type = file_util.get_file_type(entry_name)
# mode图片模式,有9种,分别为1,L,P,RGB,RGBA(有alpha通道),CMYK,YCbCr,I,F
if img.mode != "RGBA":
if image_type == ".png" and not filename_without_suffix.endswith(".9") \
and zip_info.compress_size >= 10*1024:
non_alpha = True
except OSError:
pass
finally:
file_info.image_size = image_size
file_info.non_alpha = non_alpha
apk_file_list.append(file_info)
continue
其中img.size和img.mode是核心代码,可以获取图片尺寸和图片模式。
3.6 重复资源
重复资源的获取是通过对比资源文件的MD5值,一个APP中存在不同名称的图片具有相同的MD5值,那么这些图片便重复需要删除只保留一份即可,实现方法不再赘述。
以上分析可以满足APP资源对比,分析资源增减情况需求,更加直观分析APP中大小增长过快模块。但在APK瘦身中还有一个更行而有效方法——无用资源,无用资源包含res目录下资源以及assets目录下资源。在分析这两块无用资源的首要工作:了解AAPT打包APK资源方法。在AAPT打包资源文件中,项目中的AndroidManifest.xml文件和布局文件XML都会被编译,然后生成相应的R.java,存放在APP的res目录下的资源在打包前会被编译成二进制文件,并且为每一个该类文件赋予一个resource id。对于该类资源的访问,应用层则是通过resource id进行访问。Android应用在编译过程中AAPT工具会对资源文件进行编译,并生成一个resource.arsc文件,而resource.arsc文件其实就是一个文件索引表,所有res目录下资源都会在这个文件中,所以分析res/无用资源首要就是要简单了解下resources.arsc文件。
在我们使用apktool进行反编译后,res/values/public.xml就是分析resources.arsc而来的。了解resource.arsc文件,可以编写Python代码进行分析,获取对应resource文件。当然使用apktool亦可反编译出相同文件。
3.7.1.res目录无用资源
AAPT将res/目录下资源文件经过混淆直接保存至r目录下,且res资源可以被values目录下xml、非values目录下xml、AndroidManifest.xml、java代码中被引用,所以要分析该目录下的无用资源,需要步骤如下:
1)解析R.txt,并将R.txt中resources_ids作为全部资源set<resource_id> unusedset列表中。
2)分析resources.arsc文件,得到被引用资源文件列表。
该部分可以分为2部分:values资源扫描;非values资源(像layout、animation、drawable等目录下xml文件)扫描。这是因为values资源可以引用图片资源,而非values资源不仅可以引用图片资源且可以被引用。①values文件夹下文件直接引用资源列表+manifest.xml文件中引用资源列表,均放入set<resource_id> value_references_set列表中。②非values文件夹,且是xml文件中引用资源列表,放入map<resource_id,set<resource_id>> non_values_references_map。
3)分析DEX转码为smali代码中直接引用的资源,放入set<resource_id>code_ref_set列表中。
4)将以上所有set中数据合并至一个set中references_ref_set列表中。
5)unusedset删除references_ref_set中数据。
6)unusedset删除 shared_res_public.xml中标注的自定义so库(即aura)中引用的资源。
7)unusedset删除需要被忽略的数据。
以上7个步骤即可得到无用资源列表,当然会涉及到资源文件还原等工作,详情请参照上文。
核心实现代码如下:
def read_resource_txt_file(mapping_name):
resource_txt_path = mapping_name + "/AndroidJD-Phone/hotfix/R.txt"
# R.txt解析结果
resource_def_map = dict({})
unused_res_set = set({})
try:
r_txt_file = open(resource_txt_path, "r")
line = r_txt_file.readline()
while line:
columns = line.split(" ")
if len(columns )>= 4:
resource_name = "R."+columns[1]+"."+columns[2]
if not columns[0].endswith("[]") and columns[3].startswith("0x"):
if columns[3].startswith("0x01"):
print("ignore system resource %s", resource_name)
else:
res_id = __parse_resource_id(columns[3].strip())
if res_id:
resource_def_map[res_id] = resource_name
if not ignore_resource(resource_name):
unused_res_set.add(resource_name)
else:
# print("ignore resource %s", resource_name)
# styleable资源读取
........
line = r_txt_file.readline()
except Exception as e:
raise Exception(resource_txt_path+" file Error,", e)
finally:
return resource_def_map, styleable_map, unused_res_set
# 遍历smali代码,获取引用资源的res_id
def read_smali_files(smali_path, resource_def_map, styleable_map, r_class_proguard_map):
resource_def_set = set({})
try:
if file_util.is_readable(smali_path):
smali_file = open(smali_path, "r")
smali_line = smali_file.readline().strip()
while smali_line:
line = smali_line.lstrip('\t')
if line and line.strip().startswith("const "):
columns = line.split(",")
if len(columns) == 2:
res_id = __parse_resource_id(columns[1].strip())
if res_id and (res_id in resource_def_map.keys()):
# print(resource_def_map[res_id])
resource_def_set.add(resource_def_map[res_id])
elif line.startswith("sget "):
# styleable资源引用在smali代码中呈现
.....
smali_line: = smali_file.readline()
except Exception as e:
raise Exception("read_smali_files Error,", e)
return resource_def_set
# 遍历res/目录下xml文件
def decode_resources(apk_path_dir, res_guard_map):
non_value_reference_map = dict({})
resource_res_used_set = set({})
if not apk_path_dir.endswith("/"):
apk_path_dir += "/"
res_dir = apk_path_dir+"res/"
if not os.path.exists(res_dir):
res_dir = apk_path_dir + 'r/'
for paths, sub_paths, files in os.walk(res_dir):
path_dirs = paths.split("/")
resource_dir = path_dirs[len(path_dirs)-1]
res_type = str(resource_dir).split('-')[0]
if res_type and "values" in res_type:
for file in files:
# 解析values下xml文件,并返回引用资源ID列表
value_references_set = xml_decoder(os.path.join(paths, file), res_guard_map)
for resource in value_references_set:
resource_res_used_set.add(resource)
elif res_type:
for file in files:
if file_util.is_legal_file(os.path.join(paths, file)) and file.endswith(".xml") \
and not ignore_resource(os.path.join(paths, file)):
# 非values xml引用res资源
res_used_set = xml_decoder(os.path.join(paths, file), res_guard_map)
resource = "R." + str(res_type) + "." + file[:file.rfind(".")]
if resource in res_guard_map.keys():
before_resource = res_guard_map[resource].split("R."+res_type+".")[1].replace('.', '_')
res_guard_resource = "R."+res_type+"." + before_resource
resource = res_guard_resource
if resource in non_value_reference_map.keys():
reference_set = non_value_reference_map.get(resource)
for item in res_used_set:
reference_set.add(item)
non_value_reference_map[resource] = reference_set
else:
non_value_reference_map[resource] = res_used_set
# AndroidManifest.xml文件读取,并返回引用的资源ID列表
manifest_path = apk_path_dir + "AndroidManifest.xml"
if not file_util.is_legal_file(manifest_path):
logging.warning("File %s is illegal!" % manifest_path)
return
manifest_ref_set = xml_decoder(manifest_path, res_guard_map)
for reference in manifest_ref_set:
resource_res_used_set.add(reference)
return resource_res_used_set, non_value_reference_map
3.7.2.assets目录无用资源分析
经过分析AAPT打包APK流程可知,assets/下文件直接被保留至APK中,所以DEX可以直接使用名称进行访问
assets/目录下无用资源分析流程:
1)查找assets目录下所有文件,并将文件路径保存至set<asset_id> assets_path_set中。
2)遍历smali代码,查找代码中引用的assets资源,并将查找结果放入set<asset_id> asset_ref_set中。
3)assets_path_set去除asset_ref_set剩余的资源,都是未被使用的。
核心代码:
def find_asset_file(asset_dir):
if asset_dir and os.path.exists(asset_dir) and os.path.isdir(asset_dir):
for paths, sub_paths, files in os.walk(asset_dir):
for file in files:
asset_file_sub_dir = paths.split(asset_dir)[1]
# assets目录下图片资源均放入unused_asset_set
unused_asset_set.add(os.path.join(asset_file_sub_dir, file))
# 遍历smali代码,获取引用资源的res_id
def read_smali_files(smali_path):
if file_util.is_readable(smali_path):
smali_file = open(smali_path, "r")
smali_line = smali_file.readline()
while smali_line:
line = smali_line.strip()
if line and line.startswith("const"):
# smali代码中引用资源ID
do something...
elif line.startswith("sget"):
# smali代码中引用资源ID数组,例如int[] styleable
do something...
elif line.startswith("const-string"):
# 查找smali代码中引用asset资源
columns = line.split(",")
if len(columns) == 2:
asset_file_name = columns[1].strip()
if asset_file_name:
for path in unused_res_set:
if path.endswith(asset_file_name):
unused_res_set.remove(path)
smali_line = smali_file.readline()
注意:无用资源分析,只会分析Java代码中使用的资源,像React Native代码中引用资源将不会被扫描到,后期如果允许在对相关部分进行研究。
结语
使用Python我们快速实现了Android APK 的资源分析,为应用瘦身提供准确的数据支持,同时也开发了图片压缩、资源混淆、资源托管线上等工具。目前分析平台已搭建完成并进入内测阶段,期望更高效地为应用瘦身提供支持,让APK体积将到极致,降低应用分发成本,提升转化率及用户体验。