游戏发行之 iOS 切包系统设计

背景

在游戏发行行业,iOS 不像安卓会有很多的渠道,为什么 iOS 需要做一个切包系统呢?在以往 iOS 端游戏出包过程中,经常会遇到一些问题:

  1. SDK 版本更新,需依赖研发打包,研发侧经常因排期过久/人员变动/项目不维护等原因,出包时间拉长且不可控
  2. 研发出包过程,常因一些包体内容(应用名称/版本号/icon等)错误来回修改,耗时长
  3. 由于 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 内的图标的步骤如下:

  1. 上传一张 1024 * 1024 的图片,去除图片透明通道并裁剪所需的各尺寸 icon 图片
  2. 复制并替换 app 根目录内的所有 icon 图片
  3. 创建 Assets.xcassets 文件夹,导出 Assets.car 的所有图片到 Assets.xcassets 内,并替换里面所有的 icon 图片
  4. 使用 actool 将 Assets.xcassets 生成新的 Assets.car 文件
  5. 替换 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目录下的动态库和包内的资源文件,这一步就不细说,主要就是普通的文件替换。针对动态库的修改,有一些需要注意的地方:

  1. 有新增的framework:由于母包的主工程 MachO 的 Load Commands 中不包含新 framework 链接,因此单纯把 framework 复制进去系统可能也不会加载,需替换原母包中已有链接的 framework(如新的主 SDK,有链接新的 framework)。还有一种方式,使用 optool 修改主 MachO 文件,增加新的 framework 链接,但存在有一定风险。
  2. 删除已有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 重签名工具》。这里主要列下核心的代码实现:

  1. 重签名动态库
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,
             )
  1. 重签名 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)

  1. xcodebuild -exportArchive导出包体
shell 复制代码
 xcodebuild -exportArchive -archivePath xcarchive路径 -exportPath 导出目录 -exportOptionsPlist ExportOptions.plist路径

注意:如果 app 中包含 appex,ExportOptions.plist 也需包含 appex 对应的 bundle id 和签名证书信息

总结

由于篇幅有限,上述主要分享了切包平台的系统架构设计和 iOS 端切包部分实现逻辑,希望对一些小伙伴有帮助。如果有任何问题或更好的方案,欢迎大家评论区一起讨论~

相关推荐
DisonTangor12 小时前
苹果发布iOS 18.2首个公测版:Siri接入ChatGPT、iPhone 16拍照按钮有用了
ios·chatgpt·iphone
- 羊羊不超越 -12 小时前
App渠道来源追踪方案全面分析(iOS/Android/鸿蒙)
android·ios·harmonyos
Footprint_Analytics14 小时前
Footprint Analytics 助力 Sei 游戏生态增长
游戏·web3·区块链
半盏茶香18 小时前
【C语言】分支和循环详解(下)猜数字游戏
c语言·开发语言·c++·算法·游戏
PandaQue1 天前
《怪物猎人:荒野》游戏可以键鼠直连吗
游戏
2401_865854881 天前
iOS应用想要下载到手机上只能苹果签名吗?
后端·ios·iphone
白狐欧莱雅1 天前
使用python中的pygame简单实现飞机大战游戏
经验分享·python·游戏·pygame
豆本-豆豆奶1 天前
用 Python 写了一个天天酷跑(附源码)
开发语言·python·游戏·pygame·零基础教程
Leoysq1 天前
【UGUI】实现点击注册按钮跳转游戏场景
游戏·unity·游戏引擎·ugui
HackerTom2 天前
iOS用rime且导入自制输入方案
ios·iphone·rime