背景

在游戏发行行业,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 切包体系规划

功能完成

关于后台的完成细节,这儿就不具体评论,基本都是数据库的增修正查和打包使命的分发,本文首要评论下 iOS 切包逻辑的完成。

1. Info.plist 更新

首要更新App根底信息和SDK所需求的配置参数

  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
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地址可检查。

游戏发行之 iOS 切包体系规划
因为 codesign 指令读取出来的 entitlements 内容结构不好解析处理,可运用开源东西 ldid 读取 xml 格局的 entitlements 信息。

ldid -e macho途径

以下是具体的读取和修正逻辑:

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. 重签名动态库
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
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导出包体
xcodebuild -exportArchive -archivePath xcarchive途径 -exportPath 导出目录 -exportOptionsPlist ExportOptions.plist途径

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

总结

因为篇幅有限,上述首要共享了切包渠道的体系架构规划和 iOS 端切包部分完成逻辑,希望对一些小伙伴有协助。假如有任何问题或更好的计划,欢迎我们评论区一同评论~

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。