游戏发行之 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 端切包部分实现逻辑,希望对一些小伙伴有帮助。如果有任何问题或更好的方案,欢迎大家评论区一起讨论~

相关推荐
Liudef067 小时前
儿童趣味记忆配对游戏
css·游戏·css3
crazy_yun11 小时前
通用游戏前端架构设计思考
游戏
Engandend14 小时前
Flutter与iOS混合开发交互
flutter·ios·程序员
山水域16 小时前
GoogleAdsOnDeviceConversion 库的作用与用法
ios
yjm16 小时前
从一例 Lottie OOM 线上事故读源码
android·app
Lucifer晓16 小时前
记录一次Flutter项目上传App Store Connect出现“Validation failed”错误的问题
flutter·ios
Keya17 小时前
在HarmonyOS(鸿蒙)中H5页面中的视频不会自动播放
app·harmonyos·arkts
扶我起来还能学_17 小时前
uniapp Android&iOS 定位权限检查
android·javascript·ios·前端框架·uni-app
向宇it19 小时前
【unity小技巧】在 Unity 中将 2D 精灵添加到 3D 游戏中,并实现阴影投射效果,实现类《八分旅人》《饥荒》等等的2.5D游戏效果
游戏·3d·unity·编辑器·游戏引擎·材质
witton19 小时前
C语言使用Protobuf进行网络通信
c语言·开发语言·游戏·c·模块化·protobuf·protobuf-c