手游SDK—第五篇(打包踩坑篇)
Hi,最近在写打包系统的适配,一句话:特心塞。各种渠道资源问题。写这边文章的目的是希望看过之前系列并且也在从事游戏SDK开发的小伙伴少踩点坑。虽然说没有什么技术含量,但是很折腾人,故写下该文章记录下。
PS:目前的打包是Python写的运行脚本,解决思路及代码仅供大家参考。而且该篇文章是基于之前的打包逻辑来写的,建议先看下之间的打包篇,或者之前将之前的打包代码下载下来run一遍。
在写之前,大家伙先来看看之前的打包过程:
image.png通过这张图可以看到,整个打包的过程准确地来讲就是资源合并的过程,因此打包踩坑篇也就是资源合并的坑点。
PS:后面的坑点更多的会以应用宝YSDK为例子说明。这里就需要吐槽下应用宝这个YSDK渠道了。qnmlgb, diu。
坑点一:assets目录的资源怎么缺失了。
通过assets目录的资源在打包的时候,直接拷贝覆盖成当前渠道的最新版本就可以了,但是很不幸的是,当你美滋滋的打出来的包体运行的时候。。。bangbangbangbang ,哈哈,点击YSDK渠道登录没反应。
问题在哪里呢?不着急哈,首先你找一个可以正常运行的包体,反编译后跟你打出来的包体,你会发现assets目录缺少了一堆文件。
image.png那这些文件都是哪里来的?我拷贝YSDK渠道资源的时候,没有少拷贝啊!!! 哈哈哈,对你拷贝资源的时候肯定是拷贝进去,但是打包的时候没有打包进去,为什么呢?
因为这个资源是在.jar包文件里面的。请将YSDK的.jar包好压看一下就好知道了。当打包将.jar转化为smail的时候只是将源码进行了转化,没有处理资源文件,导致资源的丢失。
image.png坑点二:unknown目录的生成
细心的朋友可能通过上面的截图就可以看到,.jar文件里面除了assets目录下的资源以外还多了BeaconVersion.txt文件,而且这个文件在最开始的时候也没有打到游戏里面去,这个文件又要怎么处理呢?
其实,在回编译生成apk文件的时候,apktool工具会将非.apk文件打包放到unknown的目录下。这里可以参考下apktool源码分析。
Apktool源码解析——第一篇
Apktool源码解析——第二篇
public void decodeUnknownFiles(ExtFile apkFile, File outDir, ResTable resTable) throws AndrolibException {
LOGGER.info("Copying unknown files...");
File unknownOut = new File(outDir, UNK_DIRNAME);
ZipEntry invZipFile;
// have to use container of ZipFile to help identify compression type
// with regular looping of apkFile for easy copy
try {
Directory unk = apkFile.getDirectory();
ZipExtFile apkZipFile = new ZipExtFile(apkFile.getAbsolutePath());
// loop all items in container recursively, ignoring any that are pre-defined by aapt
Set<String> files = unk.getFiles(true);
for (String file : files) {//取出apk内所有文件名
if (!isAPKFileNames(file) && !file.endsWith(".dex")) {//不是常规文件也不是.dex文件
// copy file out of archive into special "unknown" folder
unk.copyToDir(unknownOut, file);//拷贝至unknown目录
try {
// ignore encryption
apkZipFile.getEntry(file).getGeneralPurposeBit().useEncryption(false);
invZipFile = apkZipFile.getEntry(file);
// lets record the name of the file, and its compression type
// so that we may re-include it the same way
if (invZipFile != null) {//这里把他们收集起来,如果需要回编译还可以原封不动的塞回去
mResUnknownFiles.addUnknownFileInfo(invZipFile.getName(), String.valueOf(invZipFile.getMethod()));
}
} catch (NullPointerException ignored) { }
}
}
apkZipFile.close();
} catch (DirectoryException | IOException ex) {
throw new AndrolibException(ex);
}
}
好了,通过分析这个两个坑点了,那我们的打包系统就需要适配下咯:
def handle_jar(temp_path, temp_assets_path, jar_path):
try:
status = 0
result = ''
# 将jar文件解压到对应的目录下
unzip_jar_path = jar_path.replace('.jar', '')
unzip_file = zipfile.ZipFile(jar_path, 'r')
unzip_file.extractall(unzip_jar_path)
unzip_file.close()
# 提取文件后,处理非.class文件
unzip_dir_list = os.listdir(unzip_jar_path)
for temp in unzip_dir_list:
# 如果是文件夹
if os.path.isdir(os.path.join(unzip_jar_path, temp)):
if temp == 'assets':
status, result = copy_command(os.path.join(unzip_jar_path, temp), temp_assets_path)
if not status == 0:
break
# 如果是文件,拷贝到unknown目录下
else:
unknown_path = os.path.join(temp_path, 'unknown')
if not os.path.exists(unknown_path):
os.makedirs(unknown_path)
status, result = copy_command(os.path.join(unzip_jar_path, temp), unknown_path)
if not status == 0:
break
apktool_yal_path = os.path.join(temp_path, 'apktool.yml')
status, result = handle_apktool_yml(apktool_yal_path, temp)
if not status == 0:
break
# 注意跳出循环后,判读状态
if not status == 0:
return status, result
else:
shutil.rmtree(unzip_jar_path)
return 0, u"处理jar: %s 成功 " % jar_path
except Exception as e:
return 1, str(e)
坑点三:apktool.yml文件回编译没有生效
当你处理了前面两个坑点的时候,你又以为可以美滋滋的打包了,哈哈,小样,想得太美!!! 你会发现你处理的unknown目录的资源在回编译的时候没有打到Apk里面。这又是什么鬼操作~~~ mmp的
大家请看这句:
// lets record the name of the file, and its compression type
// so that we may re-include it the same way
if (invZipFile != null) {//这里把他们收集起来,如果需要回编译还可以原封不动的塞回去
mResUnknownFiles.addUnknownFileInfo(invZipFile.getName(), String.valueOf(invZipFile.getMethod()));
}
apktool工具在反编译apk时,会把编译过程的资源信息写到apktool.yml文件中,在回编译的时候读取apktool.yml的配置信息找对应的资源文件
public void writeMetaFile(File mOutDir, Map<String, Object> meta)//键值对信息
throws AndrolibException {
DumperOptions options = new DumperOptions();
options.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK);
Yaml yaml = new Yaml(options);
try (
Writer writer = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(
new File(mOutDir, "apktool.yml")), "UTF-8"));//输出目录
) {
yaml.dump(meta, writer);
} catch (IOException ex) {
throw new AndrolibException(ex);
}
}
public void build(ExtFile appDir, File outFile) throws BrutException
{
LOGGER.info("Using Apktool " + getVersion());
MetaInfo meta = readMetaFile(appDir); //读取apktool.yml信息
this.apkOptions.isFramework = meta.isFrameworkApk;
this.apkOptions.resourcesAreCompressed = meta.compressionType;
this.apkOptions.doNotCompress = meta.doNotCompress;
//设置资源配置信息
this.mAndRes.setSdkInfo(meta.sdkInfo);
this.mAndRes.setPackageId(meta.packageInfo);
this.mAndRes.setPackageRenamed(meta.packageInfo);
this.mAndRes.setVersionInfo(meta.versionInfo);
this.mAndRes.setSharedLibrary(meta.sharedLibrary);
this.mAndRes.setSparseResources(meta.sparseResources);
if ((meta.sdkInfo != null) && (meta.sdkInfo.get("minSdkVersion") != null)) {
String minSdkVersion = (String)meta.sdkInfo.get("minSdkVersion");
this.mMinSdkVersion = this.mAndRes.getMinSdkVersionFromAndroidCodename(meta, minSdkVersion);
}
if (outFile == null) {
String outFileName = meta.apkFileName;
outFile = new File(appDir, "dist" + File.separator + ((outFileName == null) ? "out.apk" : outFileName));
}
new File(appDir, "build/apk").mkdirs();
File manifest = new File(appDir, "AndroidManifest.xml");
File manifestOriginal = new File(appDir, "AndroidManifest.xml.orig");
buildSources(appDir);
buildNonDefaultSources(appDir);
buildManifestFile(appDir, manifest, manifestOriginal);
buildResources(appDir, meta.usesFramework);
buildLibs(appDir);
buildCopyOriginalFiles(appDir);
buildApk(appDir, outFile);
buildUnknownFiles(appDir, outFile, meta);
if ((manifest.isFile()) && (manifest.exists()) && (manifestOriginal.isFile())) {
try {
if (new File(appDir, "AndroidManifest.xml").delete())
FileUtils.moveFile(manifestOriginal, manifest);
}
catch (IOException ex) {
throw new AndrolibException(ex.getMessage());
}
}
LOGGER.info("Built apk...");
}
因此,咱们在合并资源到unknown目录的时候,需要修改下apktool.yml信息
# 每复制一个文件到unknown目录下,都需要修改下
def handle_apktool_yml(yml_file_path, file_name):
try:
modify_apktool_yml(yml_file_path)
fr = open(yml_file_path, 'r')
yml_str = yaml.load(fr.read(), Loader=yaml.Loader)
fr.close()
# 不存在就添加
if not yml_str.has_key('unknownFiles'):
yml_str['unknownFiles'] = {}
unknown_files = yml_str['unknownFiles']
unknown_files[file_name] = '8' # 0 不压缩 8 压缩
fw = open(yml_file_path, 'w')
yaml.dump(yml_str, fw, Dumper=yaml.RoundTripDumper)
fw.close()
return 0, 'handle apktool.yml success'
except Exception as e:
return 1, str(e)
坑点四:!!brut.androlib.meta.MetaInfo
这个坑点,比较隐秘,是不同版本apktool工具发现的,是我在排查问题时遇到的,打包工具用的是2.3.3版本的,但是电脑配置环境变量apktool版本是2.0.1的版本,然后手动回编译时,老是报错:“could not determine a constructor for the tag 'tag:yaml.org,2002:brut.androlib.meta.MetaInfo'”。才发现高版本的的apktool工具在生成apktool.yml文件时,自动在第一行上添加了这个字段,再用版本回编译的时候报错。如果反编译和回编译用的版本都是一样的就没有这个问题。
坑点五:res目录下-V4目录问题
相信通常在新建安卓工程的时候,res目录的资源包大多是drawable、drawable-hdpi、drawable-xhdpi、values、values-hdpi等形式。但是反编译之后你就发现,mmp的目录格式改了:
image.png如果游戏资源已经存在了资源文件,当拷贝渠道的资源后,回编译时,会提示资源重复,为了处理这个问题,在合并资源文件的时候,就需提前判断反编译后的目录结构。拷贝到对应的资源文件中。
# 合并res目录资源
def merge_res_resource(task_id, tools_path, temp_path, channel_path, channel_id, channel_version, build_config):
logger = get_logger(task_id)
logger.info(u'合并res资源...')
# 合并之前修改渠道的res资源配置文件
status, result = modify_channel_res_resource(channel_path, channel_id, channel_version, build_config)
if not status == 0:
return status, result
# 需要处理下后缀是-v4的文件夹,因为游戏反编译后的资源文件大多为-v4后缀,
# 而渠道资源文件是没有后缀的,如果xxx 和 xxx-v4资源相同的话,会报错,需将两者合并
dirs = ['drawable', 'drawable-hdpi', 'drawable-ldpi', 'drawable-mdpi', 'drawable-xhdpi', 'drawable-xxhdpi',
'values', 'values-hdpi', 'values-ldpi', 'values-mdpi', 'values-xhdpi', 'values-xxhdpi']
v4_dirs = ['drawable-hdpi-v4', 'drawable-ldpi-v4', 'drawable-mdpi-v4', 'drawable-xhdpi-v4','drawable-xxhdpi-v4',
'values-hdpi-v4', 'values-ldpi-v4', 'values-mdpi-v4', 'values-xhdpi-v4', 'values-xxhdpi-v4']
temp_res_dirs = os.listdir(os.path.join(temp_path, 'res'))
channel_res_dirs = os.listdir(os.path.join(channel_path, 'res'))
difference_list = list(set(channel_res_dirs).difference(set(temp_res_dirs))) # 获取渠道特有的目录
# 保证后面渠道复制时,有对应的目录路径
for difference_dir in difference_list:
# 如果文件名为没有v4后缀的,需判断游戏目录是否存在对应的-v4目录
# 存在就不新建目录,后面直接复制到存在的对应-v4目录
# 不存在就新建该名称目录,后面直接复制到该名称目录
if difference_dir in dirs:
difference_dir_v4 = difference_dir + '-v4'
if difference_dir_v4 not in temp_res_dirs:
os.makedirs(os.path.join(temp_path, 'res', difference_dir)) # 在temp中创建该目录
# 如果文件名为有v4后缀的,需判断游戏目录是否存在对应去掉-v4后缀的目录
# 存在就不新建目录,后面直接复制到存在的对应去掉-v4后缀目录
# 不存在就新建该有v4后缀的目录,后面直接复制到该有v4名称目录
elif difference_dir in v4_dirs:
difference_dir_temp = difference_dir.replace('-v4', '')
if difference_dir_temp not in temp_res_dirs:
os.makedirs(os.path.join(temp_path, 'res', difference_dir)) # 在temp中创建该目录
# 其他名称目录直接新建
else:
os.makedirs(os.path.join(temp_path, 'res', difference_dir)) # 在temp中创建该目录
# 重新赋值
temp_res_dirs = os.listdir(os.path.join(temp_path, 'res'))
# 遍历渠道res目录
for channel_dir in channel_res_dirs:
if os.path.isdir(os.path.join(channel_path, 'res', channel_dir)):
# 如果文件名为没有v4后缀的,需判断游戏目录是否存在目录
# 如果游戏中有对应的没有v4后缀的目录,则直接合并到对应目录
# 如果游戏中不存在没有v4后缀的目录,找到对应存在的v4后缀目录
if channel_dir in dirs:
if channel_dir in temp_res_dirs:
status, result = merge_special_dirs(temp_path, channel_path, channel_dir, channel_dir)
else:
game_dir_v4 = channel_dir + '-v4'
status, result = merge_special_dirs(temp_path, channel_path, game_dir_v4, channel_dir)
if status == 0:
logger.info(u'合并目录' + channel_dir + u' 资源成功')
else:
break # 跳出循环
# 如果文件名为v4后缀的,需判断游戏目录是否存在目录
# 如果游戏中有对应的v4后缀的目录,则直接合并到对应目录
# 如果游戏中不存在v4后缀的目录,找到对应存在的去掉v4后缀目录
elif channel_dir in v4_dirs:
if channel_dir in temp_res_dirs:
status, result = merge_special_dirs(temp_path, channel_path, channel_dir, channel_dir)
else:
game_dir_temp = channel_dir.replace('-v4', '')
status, result = merge_special_dirs(temp_path, channel_path, game_dir_temp, channel_dir)
if status == 0:
logger.info(u'合并目录' + channel_dir + u' 资源成功')
else:
break # 跳出循环
# 其他的文件夹直接复制就可以了
else:
status, result = copy_command(os.path.join(channel_path, 'res', channel_dir),
os.path.join(temp_path, 'res', channel_dir))
if status == 0:
logger.info(u'合并目录' + channel_dir + u' 资源成功')
else:
break # 跳出循环
# 注意跳出循环后,判读状态
if not status == 0:
return status, result
# 需要处理下一种特殊情况:游戏反编译后,没有v4后缀的目录和有v4后缀的目录同时存在,且文件夹合并后会有重复的文件存在,
# 需删掉其中一个,保留v4目录的(这个bug是在windows上发现的,但是在MAC上就打包成功,很奇怪)
temp_res_dirs = os.listdir(os.path.join(temp_path, 'res'))
for dir in temp_res_dirs:
if dir in dirs:
dir_v4 = dir + '-v4'
if dir_v4 in temp_res_dirs:
dir_file_list = os.listdir(os.path.join(temp_path, 'res', dir))
dir_v4_file_list = os.listdir(os.path.join(temp_path, 'res', dir_v4))
for file in dir_file_list:
if file in dir_v4_file_list:
os.remove(os.path.join(temp_path, 'res', dir_v4, file))
return 0, u'合并 res 资源成功'
坑点六:res目录下values目录下arrays.xml/colors.xml/demens.xml/strings.xml资源丢失问题:
res资源拷贝拷贝完之后,渠道的资源同名资源会覆盖掉游戏反编译后原有的资源来保证资源是渠道的最新版本,但是需要注意的是values目录下arrays.xml/colors.xml/demens.xml/strings.xml的资源丢失问题,因为游戏apk包反编译后的资源信息都打包到这几个文件里面了,覆盖后,找不到对应的资源会导致各种问题,所以需要针对这几个资源文件做合并处理。
# 判断资源文件是否在游戏的文件列表里,在就合并,不在就复制进去
value_xml = ''
for value_xml in channel_values_list:
if value_xml in game_values_list:
game_value_xml = os.path.join(temp_path, 'res', game_dir, value_xml)
channel_value_xml = os.path.join(channel_path, 'res', channel_dir, value_xml)
status, result = merge_res_xml(game_value_xml, channel_value_xml)
if not status == 0:
break # 跳出循环
else:
status, result = copy_command(os.path.join(channel_path, 'res', channel_dir, value_xml),
os.path.join(temp_path, 'res', game_dir))
if not status == 0:
break # 跳出循环
坑点七:res目录下values/values-hdpi等目录下values.xml、values-hdpi.xml、values-ldpi.xml资源冲突问题:
values.xml准确来说就是arrays.xml/colors.xml/demens.xml/strings.xml等的结合体:
image.png坑点六处理的资源合并没有处理到这个资源,在生成R文件的时候就会提示资源冲突了。需要先将values.xml转化为arrays.xml/colors.xml/demens.xml/strings.xml等文件。
# 需要处理下values.xml/values-hdpi.xml等文件
# 如果游戏之前接过渠道对应的资源,但是没有删除干净,生成的游戏母包apk,反编译后的资源strings.xml等格式
# 因此将渠道存在的values的文件先转化为资源strings.xml等格式后再和游戏的资源合并
def compile_values_xml(xml_dir_path, xml_file_path):
try:
values_xml_dom = ET.parse(xml_file_path)
# 解析xxx-array节点,并合并到arrays.xml
arrays = find_nodes(values_xml_dom, 'array')
for temp_node in find_nodes(values_xml_dom, 'string-array'):
arrays.append(temp_node)
for temp_node in find_nodes(values_xml_dom, 'integer-array'):
arrays.append(temp_node)
if arrays:
arrays_xml_path = create_resources_xml(xml_dir_path, 'arrays.xml')
arrays_xml_dom = ET.parse(arrays_xml_path)
arrays_xml_root = arrays_xml_dom.getroot()
# 返回arrays.xml已存在的节点
arrays_has_nodes = []
for node in arrays_xml_root:
arrays_has_nodes.append(node.get('name'))
for arrays_node in arrays:
arrays_node_name = arrays_node.get('name')
if arrays_node_name not in arrays_has_nodes:
arrays_xml_root.append(arrays_node)
arrays_xml_dom.write(arrays_xml_path, encoding='UTF-8', xml_declaration=True)
# 解析string节点,并合并到strings.xml
strings = find_nodes(values_xml_dom, 'string')
if strings:
string_xml_path = create_resources_xml(xml_dir_path, 'strings.xml')
strings_xml_dom = ET.parse(string_xml_path)
strings_xml_root = strings_xml_dom.getroot()
# 返回strings.xml已存在的节点
strings_has_nodes = []
for node in strings_xml_root:
strings_has_nodes.append(node.get('name'))
for strings_node in strings:
strings_node_name = strings_node.get('name')
if strings_node_name not in strings_has_nodes:
strings_xml_root.append(strings_node)
strings_xml_dom.write(string_xml_path, encoding='UTF-8', xml_declaration=True)
# 解析color节点,并合并到colors.xml
colors = find_nodes(values_xml_dom, 'color')
if colors:
colors_xml_path = create_resources_xml(xml_dir_path, 'colors.xml')
colors_xml_dom = ET.parse(colors_xml_path)
colors_xml_root = colors_xml_dom.getroot()
# 返回colors.xml已存在的节点
colors_has_nodes = []
for node in colors_xml_root:
colors_has_nodes.append(node.get('name'))
for colors_node in colors:
colors_node_name = colors_node.get('name')
if colors_node_name not in colors_has_nodes:
colors_xml_root.append(colors_node)
colors_xml_dom.write(colors_xml_path, encoding='UTF-8', xml_declaration=True)
# 解析style节点,并合并到styles.xml
styles = find_nodes(values_xml_dom, 'style')
if styles:
styles_xml_path = create_resources_xml(xml_dir_path, 'styles.xml')
styles_xml_dom = ET.parse(styles_xml_path)
styles_xml_root = styles_xml_dom.getroot()
# 返回styles.xml已存在的节点
styles_has_nodes = []
for node in styles_xml_root:
styles_has_nodes.append(node.get('name'))
for styles_node in styles:
styles_node_name = styles_node.get('name')
if styles_node_name not in styles_has_nodes:
styles_xml_root.append(styles_node)
styles_xml_dom.write(styles_xml_path, encoding='UTF-8', xml_declaration=True)
# 解析dimen节点,并合并到dimens.xml
dimens = find_nodes(values_xml_dom, 'dimen')
if dimens:
dimens_xml_path = create_resources_xml(xml_dir_path, 'dimens.xml')
dimens_xml_dom = ET.parse(dimens_xml_path)
dimens_xml_root = dimens_xml_dom.getroot()
# 返回dimens.xml已存在的节点
dimens_has_nodes = []
for node in dimens_xml_root:
dimens_has_nodes.append(node.get('name'))
for dimens_node in dimens:
dimens_node_name = dimens_node.get('name')
if dimens_node_name not in dimens_has_nodes:
dimens_xml_root.append(dimens_node)
dimens_xml_dom.write(dimens_xml_path, encoding='UTF-8', xml_declaration=True)
# 将values.xml/values-hdpi.xml转化完之后就删除当前的文件
os.remove(xml_file_path)
return 0, 'compile %s 成功' % xml_file_path
except Exception as e:
return 1, str(e)
坑点八:渠道文件修改坑点集合(后续待续...)
1、YSDK渠道ysdkconf.ini文件解析
文件不是标准的.ini的标准格式,需要额外处理,同时文件的字符集是utf-8-sig,读取后第一行老是多一个空格,用.ini格式读写时,老报错,需先处理下字符集。
#
# 这里需要处理下:因为ysdkconf.ini的原始文件不是.ini的标准格式:
# [default]
# key1 = value1
# key2 = value2
# 需要添加头[YSDK]
# 而且ysdkconf.ini的字符集为utf-8-sig,需要处理下为utf-8(蛋疼)
#
fr = codecs.open(modify_file_path, "r", "utf-8-sig")
ini_text = fr.read()
fr.close()
fw = codecs.open(modify_file_path, 'w+', 'utf-8')
fw.write(ini_text)
fw.close()
head = 'YSDK'
write_file_insert_specific_row(modify_file_path, 0, '[' + head + ']')
# 解析.ini格式内容 (解决ConfigParser库没法保留注释和强制转化为小写问题)
config = RawConfigParser()
config.readfp(open(modify_file_path, 'r'))
结语:
坑点记录的详细代码,可到开源项目打包工具下载:PackageApkTool
该工具后续持续完善,欢迎大家star