背景
在游戏发行行业,iOS 不像安卓会有很多的渠道,为什么 iOS 需要做一个切包系统呢?在以往 iOS 端游戏出包过程中,经常会遇到一些问题:
- SDK 版本更新,需依赖研发打包,研发侧经常因排期过久/人员变动/项目不维护等原因,出包时间拉长且不可控
- 研发出包过程,常因一些包体内容(应用名称/版本号/icon等)错误来回修改,耗时长
- 由于 SDK 的功能模块是插件化形式(多个动态库,可插拔),如果需要添加/删除某个插件,需依赖研发调整出包
因此,iOS 端需要一套切包系统,支持 SDK 替换/更新、包体信息修改等一系列功能。
系统架构
支持 iOS 切包的前提是 SDK 需是动态库,因为动态库 SDK 是放在 app 路径下的 frameworks 目录下,可直接替换。而静态库 SDK 是跟随游戏代码一起编译到游戏主工程的 MachO 文件中。
切包系统是基于 Python/Django/MySQL 等技术栈来开发,支持安卓/iOS 的游戏、母包、配置管理等功能,使用了 Celery/Redis 作为任务分发框架,实现多任务、排队、分布式部署等机制,最后打包机的出包目录,通过 NFS 挂载到共享盘,通过 Nginx 对外下载。
整体的系统架构图如下:
功能实现
关于后台的实现细节,这里就不具体讨论,基本都是数据库的增删改查和打包任务的分发,本文主要讨论下 iOS 切包逻辑的实现。
1. Info.plist 更新
主要更新App基础信息和SDK所需要的配置参数
python
def update_info_plist(app_path):
"""
修改Info.plist
"""
# 读取Info.plist
file_path = os.path.join(app_path, "Info.plist")
with open(file_path, 'rb') as f:
info = plistlib.load(f)
# 基础参数
info["CFBundleIdentifier"] = "包名"
info["CFBundleDisplayName"] = "App名称"
info["CFBundleShortVersionString"] = "外置版本号"
info["CFBundleVersion"] = "内置版本号"
# 允许HTTPS
info['NSAppTransportSecurity'] = {
"NSAllowsArbitraryLoads": True
}
# 添加QueryScheme
query_schemes = info.get("LSApplicationQueriesSchemes", []) + [
"weixin"
]
info["LSApplicationQueriesSchemes"] = list(set(query_schemes))
# 添加 URL Scheme
url_types = info.get("CFBundleURLTypes", [])
url_schemes = []
for t in url_types:
url_schemes.extend(t.get("CFBundleURLSchemes", []))
add_schemes = ["myapp"]
for scheme in add_schemes:
if scheme and scheme not in url_schemes:
url_types.append({
"CFBundleURLSchemes": [scheme]
})
info["CFBundleURLTypes"] = url_types
# ...
# 保存Info.plist
with open(file_path, 'wb') as f:
plistlib.dump(info, f)
2. 修改图标
iOS 读取图标的方式是先去info.plist读取图标的名称,再在 app 根目录或 Assets.car 中找到对应的图片。因此替换 ipa 内的图标的步骤如下:
- 上传一张 1024 * 1024 的图片,去除图片透明通道并裁剪所需的各尺寸 icon 图片
- 复制并替换 app 根目录内的所有 icon 图片
- 创建 Assets.xcassets 文件夹,导出 Assets.car 的所有图片到 Assets.xcassets 内,并替换里面所有的 icon 图片
- 使用 actool 将 Assets.xcassets 生成新的 Assets.car 文件
- 替换 Assets.car
python
def update_icon(app_path, icon_path):
"""
替换icon
:param app_path: app 路径
:param icon_path: icon 路径
"""
# 创建临时工作区
work_path = get_temp_workspace()
try:
# 读取icon名字
with open(os.path.join(app_path, "Info.plist"), 'rb') as f:
info = plistlib.load(f)
f.close()
icon_name = info.get('CFBundleIcons', info.get('CFBundleIcons~ipad', {})).get('CFBundlePrimaryIcon',
{}).get(
'CFBundleIconName')
# 创建xcassets目录
assets_dir = os.path.join(work_path, "Assets.xcassets")
os.mkdir(assets_dir)
# 导出原来car文件的图片
app_car_path = os.path.join(app_path, "Assets.car")
export_car_files(car_file=app_car_path, export_dir=assets_dir)
# 删除car文件原来icon
files = os.listdir(assets_dir)
for file in files:
if re.match(f"^{icon_name}.*.png$", file):
os.remove(os.path.join(assets_dir, file))
# 删除app原来icon
files = os.listdir(app_path)
for file in files:
if re.match(f"^{icon_name}.*.png$", file):
os.remove(os.path.join(app_path, file))
# 去除icon透明通道
new_icon_path = os.path.join(work_path, f"{uuid1()}.png")
remove_transparency(Image.open(icon_path)).save(new_icon_path)
# 生成各种尺寸icon
icon_assets_dir = create_icon_assets(
icon_path=new_icon_path,
dst=assets_dir,
icon_name=icon_name,
)
# 复制icon到app目录
files = os.listdir(icon_assets_dir)
for file in files:
if re.match(f"^{icon_name}.*.png$", file):
shutil.copyfile(
os.path.join(icon_assets_dir, file),
os.path.join(app_path, file),
)
# 生成car文件
assetcatalog_dependencies = os.path.join(
work_path, "assetcatalog_dependencies")
assetcatalog_generated_info = os.path.join(
work_path, "assetcatalog_generated_info.plist")
pathlib.Path(assetcatalog_dependencies).touch()
pathlib.Path(assetcatalog_generated_info).touch()
command = f"""
/Applications/Xcode.app/Contents/Developer/usr/bin/actool \
--output-format human-readable-text \
--notices \
--warnings \
--export-dependency-info {assetcatalog_dependencies} \
--output-partial-info-plist {assetcatalog_generated_info} \
--app-icon {icon_name} \
--compress-pngs \
--enable-on-demand-resources YES \
--sticker-pack-identifier-prefix com.yich.test.sticker-pack. \
--target-device iphone \
--target-device ipad \
--minimum-deployment-target 11.0 \
--platform iphoneos \
--product-type com.apple.product-type.application \
--compile '{work_path}' \
'{assets_dir}'
"""
sh(command, cwd=work_path)
# 复制新car
new_car_path = os.path.join(work_path, "Assets.car")
shutil.copyfile(new_car_path, app_car_path)
except Exception as e:
raise e
finally:
safety_remove_dir(work_path)
def remove_transparency(img_pil, bg_colour=(255, 255, 255)):
"""
去除图片透明度
:param img_pil: 图片PIL实例
:param bg_colour: 背景颜色
:return:
"""
if img_pil.mode in ('RGBA', 'LA') or (img_pil.mode == 'P' and 'transparency' in img_pil.info):
alpha = img_pil.convert('RGBA').getchannel('A')
bg = Image.new("RGB", img_pil.size, bg_colour + (255,))
bg.paste(img_pil, mask=alpha)
return bg
else:
return img_pil
# 导出car文件这里了使用了一个开源工具,可自行下载编译成可执行文件,在脚本中使用
# 地址:https://github.com/bartoszj/acextract
def export_car_files(car_file: str, export_dir: str):
"""
导出car内图片
:param car_file: car文件
:param export_dir: 导出目录
:return:
"""
exc_file = f"acextract可执行文件路径"
sh(f'{exc_file} -i "{car_file}" -o "{export_dir}"')
def create_icon_assets(icon_path: str, dst: str, icon_name: str = "AppIcon"):
"""
生成新icon的Assets.xcassets
:param icon_path: 新icon地址
:param dst: 生成的xcassets目录
:param icon_name: icon名字
:return: 生成的xcassets下的icon目录
"""
contents = {"info": {"version": 1, "author": "xcode"}}
origin_image = Image.open(icon_path)
target_dir = os.path.join(dst, f"{icon_name}.appiconset")
if not os.path.exists(target_dir):
os.mkdir(target_dir)
# 所有尺寸的icon
images = [
{
"idiom": "iphone",
"scale": "2x",
"size": "20x20"
},
{
"idiom": "iphone",
"scale": "3x",
"size": "20x20"
},
{
"idiom": "iphone",
"scale": "2x",
"size": "29x29"
},
{
"idiom": "iphone",
"scale": "3x",
"size": "29x29"
},
{
"idiom": "iphone",
"scale": "2x",
"size": "40x40"
},
{
"idiom": "iphone",
"scale": "3x",
"size": "40x40"
},
{
"idiom": "iphone",
"scale": "2x",
"size": "60x60"
},
{
"idiom": "iphone",
"scale": "3x",
"size": "60x60"
},
{
"idiom": "ipad",
"scale": "1x",
"size": "20x20"
},
{
"idiom": "ipad",
"scale": "2x",
"size": "20x20"
},
{
"idiom": "ipad",
"scale": "1x",
"size": "29x29"
},
{
"idiom": "ipad",
"scale": "2x",
"size": "29x29"
},
{
"idiom": "ipad",
"scale": "1x",
"size": "40x40"
},
{
"idiom": "ipad",
"scale": "2x",
"size": "40x40"
},
{
"idiom": "ipad",
"scale": "1x",
"size": "50x50"
},
{
"idiom": "ipad",
"scale": "2x",
"size": "50x50"
},
{
"idiom": "iphone",
"scale": "1x",
"size": "57x57"
},
{
"idiom": "iphone",
"scale": "2x",
"size": "57x57"
},
{
"idiom": "ipad",
"scale": "1x",
"size": "72x72"
},
{
"idiom": "ipad",
"scale": "2x",
"size": "72x72"
},
{
"idiom": "ipad",
"scale": "1x",
"size": "76x76"
},
{
"idiom": "ipad",
"scale": "2x",
"size": "76x76"
},
{
"idiom": "ipad",
"scale": "2x",
"size": "83.5x83.5"
},
{
"idiom": "ios-marketing",
"scale": "1x",
"size": "1024x1024"
},
]
def float_to_string(n: float):
n = str(n).rstrip('0') # 删除小数点后多余的0
n = int(n.rstrip('.')) if n.endswith('.') else float(n) # 只剩小数点直接转int,否则转回float
return n
for image in images:
size = float(image["size"].split("x")[0])
scale = int(image["scale"].split("x")[0])
size_str = float_to_string(size)
idiom = image["idiom"]
img_filename = f"{icon_name}{size_str}x{size_str}"
if int(scale) != 1:
img_filename += f'@{scale}x'
if idiom != 'iphone':
img_filename += f'~{idiom}'
img_filename += '.png'
image["filename"] = img_filename
real_size = int(size * scale)
i = origin_image.resize((real_size, real_size), Image.ANTIALIAS)
i.save(os.path.join(target_dir, img_filename))
contents["images"] = images
file = open(os.path.join(target_dir, "Contents.json"), "w")
file.write(json.dumps(contents))
file.close()
return target_dir
3. 替换 SDK 和资源文件
替换Frameworks目录下的动态库和包内的资源文件,这一步就不细说,主要就是普通的文件替换。针对动态库的修改,有一些需要注意的地方:
- 有新增的framework:由于母包的主工程 MachO 的 Load Commands 中不包含新 framework 链接,因此单纯把 framework 复制进去系统可能也不会加载,需替换原母包中已有链接的 framework(如新的主 SDK,有链接新的 framework)。还有一种方式,使用 optool 修改主 MachO 文件,增加新的 framework 链接,但存在有一定风险。
- 删除已有framework:由于主 MachO 中有 framework 的链接,直接删除会导致加载失败而崩溃。因为我们的 SDK 组件间是利用 Objc 的 runtime 机制互相调用,所以就算模块(方法)不存在也不会导致崩溃,目前的做法是先生成一个空的动态库(empty.framework),把名字改成需删除的framework名称并替换来达到删除的效果。当然使用 optool 也可以实现删除 framework 的效果,但同样具有一定风险。
以上是目前我们替换 SDK 方式,如果大家有更好的方案,欢迎大家一起讨论~
4. 修改主 MachO 的 entitlements 信息
大家都知道如果 App 需要苹果登录、推送等权限,需要创建对应的描述文件(mobileprovision)和打包时勾选配置相应的内容,最终编译出来的MachO 中也会包含权限的信息,利用系统的 codesign -d --entitlements macho地址
可查看。 由于 codesign 命令读取出来的 entitlements 内容结构不好解析处理,可使用开源工具 ldid 读取 xml 格式的 entitlements 信息。
shell
ldid -e macho路径
以下是具体的读取和修改逻辑:
python
def generate_app_entitlements(
app_path: str,
mobileprovision_path: str,
is_release: bool,
bundle_id: str,
associated_domains: [str]
) -> str:
"""
生成新的 Entitlements
:param app_path: app 路径
:param mobileprovision_path: 描述文件路径
:param is_release: 是否正式包
:param bundle_id: 包名
:param associated_domains: 添加的通用链接域名
:return:
"""
# 读取描述文件内容
mobileprovision = Mobileprovision(file=mobileprovision_path)
# 读取主MachO的entitlements
info_plist_data = get_plist_data(os.path.join(app_path, 'Info.plist'))
executable_file_path = os.path.join(app_path, info_plist_data.get('CFBundleExecutable'))
origin_entitlements, _ = sh(f'ldid -e "{executable_file_path}"')
entitlements_dict = plistlib.loads(origin_entitlements.encode('utf-8'))
# 增加需要的权限
entitlements_dict["com.apple.developer.applesignin"] = ["Default"] # 苹果登录
entitlements_dict["aps-environment"] = "production" if is_release else "development" # 推送
origin_domains = entitlements_dict.get("com.apple.developer.associated-domains", []) # Universal Link
entitlements_dict["com.apple.developer.associated-domains"] = list(set(origin_domains + associated_domains))
# 基础信息
entitlements_dict["application-identifier"] = f"{mobileprovision.team_id}.{bundle_id}" # 包名
entitlements_dict["com.apple.developer.team-identifier"] = mobileprovision.team_id # team id
if is_release:
entitlements_dict["beta-reports-active"] = True
entitlements_dict["get-task-allow"] = False
else:
entitlements_dict.pop("beta-reports-active", None)
entitlements_dict["get-task-allow"] = True
# 删除描述文件中不包含的key
for key in list(entitlements_dict.keys()):
if key not in mobileprovision.entitlements:
entitlements_dict.pop(key)
return plistlib.dumps(entitlements_dict).decode(encoding='utf-8')
我们修改后的 entitlements 信息在下一步的重签名中会使用到。
5. App 重签名
App 重签名的逻辑这里就不再展开详细讨论,具体可查看以前分享的文章《教你实现一个 iOS 重签名工具》。这里主要列下核心的代码实现:
- 重签名动态库
python
def codesign_dynamic_library(app_path, certificate_name, logger):
"""
重签动态库
:param app_path: app路径
:param certificate_name: 证书名称
:param logger: 日志
:return:
"""
result, _ = sh(
f'find -d "{app_path}/Frameworks" -name "*.dylib" -o -name "*.framework"',
logger=logger,
)
resign_list = result.split('\n')
for item in resign_list:
if len(item) > 0:
sh(
f'''/usr/bin/codesign -vvv --continue -f -s "{certificate_name}" --generate-entitlement-der --preserve-metadata=identifier,flags,runtime "{item}"''',
logger=logger,
)
- 重签名 app
python
def codesign_app(app_path, certificate_name, entitlement, logger):
"""
重签app
:param app_path: app路径
:param certificate_name: 证书名称
:param entitlement: 权限内容
:param logger: 日志
:return:
"""
# 生成entitlement文件
entitlement_path = os.path.join(temp_dir(), str(uuid1()))
with open(entitlement_path, 'w') as f:
f.write(entitlement)
sh(f'''/usr/bin/codesign -vvv -f -s "{certificate_name}" --entitlements "{entitlement_path}" --generate-entitlement-der "{app_path}"''',
logger=logger)
os.remove(entitlement_path)
注意:如果 app 中包含 appex,也是需要对其进行重签名(类似app)
xcodebuild -exportArchive
导出包体
shell
xcodebuild -exportArchive -archivePath xcarchive路径 -exportPath 导出目录 -exportOptionsPlist ExportOptions.plist路径
注意:如果 app 中包含 appex,ExportOptions.plist 也需包含 appex 对应的 bundle id 和签名证书信息
总结
由于篇幅有限,上述主要分享了切包平台的系统架构设计和 iOS 端切包部分实现逻辑,希望对一些小伙伴有帮助。如果有任何问题或更好的方案,欢迎大家评论区一起讨论~