本文最后更新于 1216 天前,其中的信息可能已经有所发展或是发生改变。
讲技术之前先讲业务,不基于具体业务的编码属于空中楼阁
游戏行业公司大体可以分为四类:研发商、发行商、游戏平台或渠道、其他辅助相关公司
什么是发行
是游戏推广链条中的一部分,以 买量头部公司 37 举例,头部公司不仅自己有研发还拥有整个闭环的买量生态。相对应的小作坊研发在研发产品后是无力承担买量的试错以及用户的运营维护,所以就会和相对体量大的公司合作,授权发行。
什么是发行 SDK
发行 SDK 指的是 移动端技术人员开发的,用来和对接 cp 基础功能以及 自身分发渠道的 SDK
- 和对接 CP 指的是,封装好发行的业务逻辑,提供对外接口给 cp 对接人员调用,一般是 初始化 登录 登出 支付 以及基本的生命周期,对 cp 提供的接口和 渠道 SDK 对发行提供的外部接口大致一致
- 发行 SDK 在交给 CP 对接完成后,会出一个母包给回发行,发行用这个母包去对接分发渠道
发行自动化打包脚本
发行自动化打包脚本就是用来一键分发渠道,只需要对接一次渠道,后续不同游戏再分发这个渠道可直接通过脚本完成打包,发行自动化脚本一般和发行 SDK 是配套的,因为不同的渠道的特殊处理逻辑,每家发行公司会有自己的需求,所以发行 SDK 的开发后面有空可能会单独写一下blog
1. 合并 lib ,assets, 以及 smali
#apktool 反编译后的目录
parentsApk = "/cp/targetDecompile"
channelApk = "/channel/demoChannel/channelDecompile"
def copyTree( src, dst, force=False):
if os.path.exists(src) is False:
return
os.makedirs(dst, 0o755, exist_ok=True)
dstFile = os.path.join(dst, os.path.basename(src))
if os.path.isfile(src):
if force or not os.path.exists(dstFile):
shutil.copy(src, dst)
return
fileList = os.listdir(src)
for fileItem in fileList:
copyTree(os.path.join(src, fileItem), dstFile, force)
return True
def concatePath(src,des):
return os.path.join(src,des)
#处理 lib 和 assets 直接合并即可
copyTree(concatePath(parentsApk,"lib"), channelApk)
copyTree(concatePath(parentsApk,"assets"), channelApk)
# 下面是处理 smali 文件的逻辑,该逻辑以 cp 母包 文件为主,
#cp 会替换 渠道 sdk 中同路径同名的文件。不想要这个效果,可以自己定义一下替换逻辑
copyTree(concatePath(parentsApk,"smali"), channelApk)
for i in range(2,10):#最多限制 cp 游戏包 有 10个 dex 文件夹,一般不会超过这个数
if os.path.exists(concatePath(parentsApk,'smali_classes'+str(i))):
copyTree(concatePath(parentsApk,'smali_classes'+str(i)), channelApk )
2. 合并 apktool.yml
def ReadApktool( path):
# 去掉文件首行
import yaml
with open(path, "r") as fp:
fp.readline()
apk = yaml.load(fp.read(), Loader=yaml.UnsafeLoader)
return apk
def merge(GAPK: dict, CAPK: dict):
if isinstance(GAPK, dict):
for key, value in GAPK.items():
if isinstance(value, dict):
merge(GAPK[key], CAPK[key])
elif isinstance(value, list):
CAPK[key] = list({*GAPK[key], *CAPK[key]})
else:
CAPK[key] = GAPK[key]
_GAPK, _CAPK = concatePath(parentsApk,"apktool.yml"), concatePath(channelApk,"apktool.yml")
GAPK, CAPK = ReadApktool(_GAPK), ReadApktool(_CAPK)
merge(GAPK, CAPK)
write(_CAPK, CAPK)#wrte函数自己实现一下,懒得写了
3. 合并 Res 资源
这里会稍微复杂一点,不懂原理的人看代码可能看的有点懵。
copyTree(concatePath(parentsApk,"res"), channelApk)
#单独合并 layout 文件夹里面的内容,以实现文件强覆盖
copyTree( concatePath(concatePath(parentsApk,"res"), "layout"), concatePath(channelApk,"res"))
# #通匹配 res 中全部包含 values 的文件夹
from pathlib import Path
from collections import namedtuple
parentsValueDirs = Path(concatePath(parentsApk,"res")).rglob("**/values*/")
def findFile( path, file):
for path, subdir, files in os.walk(path):
if file in files:
return os.path.join(path, file)
def findNodes( xml, parentTag, childTag):
return xml.xpath(f"//{parentTag}")[0], xml.xpath(f"//{parentTag}//{childTag}")
def mergePublic( mergeSource, mergeMain, savePath):
from lxml import etree
import xmltodict
# --读取 xml 中的 public 标签 begin --
cxml = etree.parse(mergeSource, parser=etree.XMLParser(remove_blank_text=True))
txml = etree.parse(mergeMain, parser=etree.XMLParser(remove_blank_text=True))
tSource, tPublic = findNodes(txml, "resources", "public")
cSource, cPublic = findNodes(cxml, "resources", "public")
# --读取 xml 中的 public 标签 end--
contrast, mid = dict(), 0
#循环 被合并目标的public
for t in tPublic:
t = t.attrib
ttype, tname, tid = t["type"], t["name"], t["id"]#把 public 节点下的每个item 属性提取出来
#为每个类型的 type 设置一个对应的value,value 的结构默认是 {"name": set(), "id": 0},
td = contrast.setdefault(ttype, {"name": set(), "id": 0})
td["name"].add(tname)#将 这个 type 下的全部 name 记录
# 处理ID
tid = int(tid, 16)
td["id"] = tid if tid > td["id"] else td["id"]# 获取这个type对应对应最大的 id,并且保存
mid = tid if tid > mid else mid # 获取这个 xml 里面最大的 id
#循环 目标对象的public
for c in cPublic:
c = c.attrib
ctype, cname = c["type"], c["name"] #只需要获取 type 和name,id 可以忽略,因为以被合并目标的id 为主体
if ctype not in contrast:#如果是 母包不存在的 type ,需要把 最大id 增加 65536
_mid = hex(mid + (1 << 16))[:6] + "0" * 4 # 16位进1,且后续位补0
c["id"], mid = _mid, int(_mid, 16)
elif cname in contrast[ctype]["name"]:#母包已经存在这个属性就跳过
continue
else:#其余情况直接根据母包中这个type 的最大id 然后顺延添加自增+1就可以
c["id"] = hex(contrast[ctype]["id"] + 1)
etree.SubElement(tSource, "public", c) # 添加进 tPublic
savexml(txml, savePath)#把结果保存到母包中 ,写入的逻辑就不写了,自己写一下就行
def pureAll(source, targe):
GSource, CSource = etree.parse(source), etree.parse(targe)
Groot, Croot = GSource.getroot(), CSource.getroot()
CSet = {node.attrib["name"] for node in Croot.iterchildren()}
#去重合并 没啥啊好说的,很简单的代码
for node in Groot.iterchildren():
if node.attrib["name"] not in CSet:
Croot.append(node)
savexml(CSource, targe)
for Dir in parentsValueDirs:
# copyTree( concatePath(concatePath (parentsApk,"res") , Dir.stem), concatePath(channelApk,"res"))
parentsValueDir, channelValueDir = concatePath(concatePath (parentsApk,"res") , Dir.stem),concatePath(concatePath (channelApk,"res") , Dir.stem)
for gvalue in os.listdir(parentsValueDir):
gfile = findFile(parentsValueDir, gvalue)#母包 通匹配values 中的 某个xml文件的绝对路径
cfile = findFile(channelValueDir, gvalue)#渠道包 通匹配values 中的 某个同名xml文件的绝对路径
mergeMain = "channel" #定义合并主体,一般写 渠道就行,可以保证渠道的 id 不变
if gvalue == "public.xml":
print('start public')
mergeToMain = namedtuple("mergeToMain", ["mergeSource", "mergeMain"])
merge_channel = mergeToMain(gfile, cfile)
merge_parent = mergeToMain(cfile, gfile)
merge = {"channel": merge_channel, "parent": merge_parent}[mergeMain]
mergePublic(merge.mergeSource, merge.mergeMain, cfile)
continue
#其他的 非 public.xml 用另一种方式合并
pureAll(gfile, cfile)
4. 合并 AndroidManifest.xml
def savexml( xml, path):
return xml.write(path, pretty_print=True, xml_declaration=True, encoding="utf-8")
def schemeKey(key):
scheme = "http://schemas.android.com/apk/res/android"
return f"{{{scheme}}}{key}"
def findXNode(rootDom, tag, attr, value):
return rootDom.find(f"./{tag}[@{attr}='{value}']")
if True:#执行AndroidManifest.xml 合并
parentsXMl = etree.parse(concatePath(parentsApk,("AndroidManifest.xml")))
channelXML = etree.parse(concatePath(channelApk,("AndroidManifest.xml")))
parentsXMLroot, channelXMLroot = parentsXMl.getroot(), channelXML.getroot()
package = "com.xx.aa.xx"#配置你想要的包名
parentsApplicationNode, parentsApplicationMateDateNode = findNodes(parentsXMl, "application", "meta-data")
channelApplicationNode, channelApplicationMatedateNode = findNodes(channelXML, "application", "meta-data")
# 拷贝 CP母包 application, 处理 Meta 以外的标签
for node in parentsApplicationNode.iterchildren():
print(node.tag)
if node.tag != "meta-data":
cnode = findXNode(channelApplicationNode, node.tag, "name", node.attrib[schemeKey("name")])
print("after if:")
print(cnode)
if cnode:
channelApplicationNode.remove(cnode)
channelApplicationNode.append(node)
channelApplicationMatedateNode = channelApplicationNode.xpath("//meta-data")
# # 处理 CP母包 与 渠道 的Meta
Gset = {mate.attrib.values()[0] for mate in parentsApplicationMateDateNode} # values()[0] 为 android:name
Cset = {mate.attrib.values()[0] for mate in channelApplicationMatedateNode}
nset = Gset - Cset
for mate in parentsApplicationMateDateNode:
if mate.attrib.values()[0] not in nset:
continue
channelApplicationNode.append(mate)
channelApplicationMatedateNode.append(mate)
# 省略 处理 config 与 渠道 的Meta
# 省略 处理启动 activity 如果是硬核的包,把activity 放在 cp activity 之前
# 省略 处理 launcher 启动页
# # 拷贝 CP母包 manifest
for rnode in parentsXMLroot.iterchildren():
if rnode.tag != "application":
channelXMLroot.append(rnode)
# # 替换 application 属性
channelApplicationNode.attrib.clear()
channelApplicationNode.attrib.update(parentsApplicationNode.attrib)
savexml(channelXML, concatePath(channelApk,"AndroidManifest.xml"))#省略写入逻辑
合并完成之后再根据自己的业务逻辑替换一下配置文件,不同的渠道打入不同的配置文件,这些就是属于 发行 sdk 的一些设计和架构相关的了,抽时间再写博客讲一下大概的思路。到此为止整个自动化打包脚本就完事了
PS:代码都是现写的,可能有编译问题,自己调整一下,或者看注释自己实现一遍也行,我差不多是逐行注释了,有什么问题欢迎留言