给 Xcode 工程的 AppIcon 添加版本信息

需求由来

日常开发迭代中,为了能让需求能够被及时验收、让问题 / bug 能得到及时验证,我们编写的代码需要持续地交付,为此我们搭建了 Jenkins 自动工具来交付版本。

有些时候我们会对同一个 App 并行开发不同的版本,随着不同功能的不同版本的同一 App 的交付(内部测试),产品同学和测试同学可能会搞不清楚自己手机上安装的到底是哪个版本、对应着哪个需求或问题要被验证,他们当然可以进入 App 的“关于 App”的页面查看版本信息,但是平添了操作步骤和相对应的时间成本,而且对于某些闪退问题的反馈,我们开发并不希望多启动一次 App 来破坏可能的沙盒/日志现场。

为了了却这一烦恼,我们打算结合当前在用的 Jenkins 将 App 的版本、代码节点位置、代码分支名直接展示在 AppIcon 上,以便不需要要打开 App 时也能知道这些信息。

方案调研

  1. 介入时机

    交付给产品同学和测试同学的测试包是通过部署在垃圾桶[1]上的 Jenkins 构建出来并分发的,我们配置了版本号自增,每次编译都会生成不同的 build 号,所以基于 AppIcon 的修改需要在每次构建时动态生成的,所以在 Jenkins 构建时给 AppIcon 加上这些信息是最合适的,可以放到“构建”步骤中:

  2. 展示的信息

    需要的基本信息:App 版本、构建的分支名和 commit-hash,App 版本可以在 Info.plist 或 project.pbxproj 中获取,构建分支信息可以通过 git 命令获取,问题不大🤔

  3. 技术点

    预期流程是将上述基础参数动态添加到 AppIcon 上的,涉及到一些图片处理步骤,这方面待选的方案有 ImageMagick,其他方面看起来没有技术难点

  4. 开发语言

    Shell 与系统结合紧密,与操作系统交互时有天然的优势,个人认为 Shell 编写的程序在篇幅短小时无可匹敌,但当篇幅过大时,阅读性和 debug-able 的能力急剧下降,对,其实就是我不会复杂的 Shell 🤦‍♂️。本次选择 Python,它比 Shell 更容易上手,我们可以更多地专注于业务层逻辑,如果需要轮子, Python 中应有尽有。对于图片处理,有知名库:Pillow,似乎也可以不用 ImageMagick

代码开发

  1. 定义外部接口

    虽然我们项目是通过 Jenkins 打包,但是这个功能不应该被划分为 Jenkins 流水线的一部分,应该是工程的一部分,跟随工程的迭代而完善。它的输入为外部传入的工程文件(.pbxproj)和目标 Target 名,输出即为加上了版本信息的 AppIcon 图片。最终通过命令行传参作为入口:

    1
    2
    3
    4
    5
    6
    7
    if __name__ == '__main__':

    pathes = sys.argv[1:] if len(sys.argv) > 1 else []
    if len(pathes) != 2:
    print("参数个数错误")
    else:
    pass
  2. 接收参数

    接收外部传入的 target 名和工程文件路径后,对参数作简单校验:

    1
    2
    3
    4
    5
    pbproj_filepath = os.path.abspath(pathes[0])
    target_name = pathes[1]
    if not Path(pbproj_filepath).exists():
    print("FATAL:{} 不存在".format(pbproj_filepath))
    exit(1)
  3. 获取工程配置信息

    版本号、所使用的 AppIcon 的路径信息都存在于 .pbxproj 中,接下来使用 mod-pbxproj 解析 .pbxproj

    新创建一个类:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    class ProjectInfo():
    def __init__(self, pbproject_path: str, target_name: str):
    self.pbproject_path = pbproject_path
    self.target_name = target_name
    self.project = XcodeProject.load(pbproject_path)
    self.build_configs = self.project.objects.get_configurations_on_targets(
    target_name=target_name)
    self.release_build_config = [
    b for b in self.build_configs if b.name == 'Release'][-1]

    def __find_icon_img_folder(self, icon_img_name: str, target_folder: str):
    icon_img_path = ''
    try:
    for root, folder_list, _ in os.walk(target_folder):
    for file in folder_list:
    if file == icon_img_name:
    icon_img_path = os.path.join(root, file)
    raise Getoutofloop()
    except Getoutofloop:
    pass

    return icon_img_path

    def __project_main_path(self):
    return os.path.dirname(os.path.split(self.pbproject_path)[0])

    def get_icon_image_folder(self):
    name = self.release_build_config.buildSettings['ASSETCATALOG_COMPILER_APPICON_NAME']
    if name:
    search_folder = self.__project_main_path()
    icon_img_name = name+'.appiconset'
    return self.__find_icon_img_folder(icon_img_name, search_folder)
    print('FATAL: AppICON 路径未找到')
    return ''

    def __get_info_plist_path(self):
    info_plist_path = self.release_build_config.buildSettings['INFOPLIST_FILE']
    if info_plist_path:
    xcode_placeholder_path = '$(SRCROOT)' # 可能在路径中并没有
    info_plist_path = info_plist_path.replace(
    xcode_placeholder_path, '')
    info_plist_path = info_plist_path if info_plist_path[0] != '/' else info_plist_path[1:]
    info_plist_path = os.path.join(
    self.__project_main_path(), info_plist_path)
    return info_plist_path

    def get_version_info(self):
    # 先从 project 获取,如果失败(失败定义:不全是 数字 和 '.' 组成),从 plist 获取
    def valid_version(main_version: str):
    """
    如果由 . 和 数字组成,就是合法的;如果全部由数字组成,也是合法的
    """
    return ('.' in main_version and main_version.replace('.', '').isdigit()) or main_version.isdigit()

    main_version = self.release_build_config.buildSettings['CURRENT_PROJECT_VERSION']
    if main_version and valid_version(main_version):
    return main_version
    else:
    plist_path = self.__get_info_plist_path()
    print("转向从 plist 读取版本号: {}".format(plist_path))
    plist = None
    with open(plist_path, 'rb') as rbf:
    plist = plistlib.load(rbf)
    if plist:
    return plist['CFBundleShortVersionString']
    return ''

    def get_git_branch_name(self):
    name = exe_command(['git', 'symbolic-ref', '--short', '-q', 'HEAD']).split('/')[-1]
    if len(name) == 0 or len(name.replace(' ','')) == 0:
    return os.environ.get('GIT_BRANCH','').split('/')[-1]
    return name

    def get_git_last_cmt_id(self):
    return exe_command(['git', 'rev-parse', '--short', 'HEAD'])

    ProjectInfo 中预期能够拿到版本号、AppIcon 所在路径、branch 名、commit-hash。

    • 版本号

      使用 mod-pbxproj 初始化工程,然后拿到 CURRENT_PROJECT_VERSION 对应的版本号。这里有一个坑点,Xcode 从 v10 升级到 v11 后,默认从$(MARKETING_VERSION)$(CURRENT_PROJECT_VERSION)获取主版本号,从旧版本格式的 .pbxproj 升级到 Xcode v11 后,Info.plist 中的Bundle version 甚至有可能为 $(MARKETING_VERSION).xxx ,所以这里做了一个兼容处理。事实上,如果是 Xcode v11 创建的工程版本号就比较清爽,当然对于我们这种旧版本 Xcode 创建的工程,也可以选择手动处理一次,将版本号改成新版本的样式。

    • AppIcon 路径

      拿到工程所使用的的 AppIcon 文件夹名字后,再遍历找到 AppIcon 文件夹的绝对路径,遍历时涉及到跳出双重循环的问题,这里找到了个人觉得比较 trick 的方式,即通过抛出一个已知的异常 Getoutofloop 来结束双重信息:

      1
      2
      class Getoutofloop(Exception):
      pass

      而之所以需要遍历,是因为我从 .pbxproj 中找不到可以直接“取”出来用的路径,只能使用这种不严谨的方式,如果有更优雅的方式,请不吝指教。

    • branch 名和最后一次 commit-hash

      这种获取 git 相关信息的,使用命令行应该是最快捷的,所以封装了一个简单的执行 Shell 命令的函数 exe_command:

      1
      2
      3
      def exe_command(list):
      result = subprocess.run(list, stdout=subprocess.PIPE)
      return result.stdout.decode("utf-8").strip('\n')

      获取分支名遇到的一个坑点:向 Jenkins 指定分支名后,被 pull 到本地的代码似乎是指向一个临时分支,通过 Git 命名并没能获取到分支名,所以这里通过获取 Jenkins 的环境变量 GIT_BRANCH 来获取分支名

  4. 给图片加上自定义文字信息,封装在 AddVersionInfo 中:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    class AddVersionInfo():

    def __init__(self, img_folder: str, version: str, branch_name: str, commit_id: str):
    self.img_folder = img_folder
    self.version = version
    self.branch_name = branch_name
    self.commit_id = commit_id

    @classmethod
    def __add_img_blur(cls, img, blur_rect: tuple):
    img = img.convert("RGB")
    img.load()
    mask = Image.new('L', img.size, 0)
    draw = ImageDraw.Draw(mask)
    # 左上角点,右下角点
    draw.rectangle([blur_rect[:2], img.size], fill=255)
    height = img.size[0]
    blurred = img.filter(ImageFilter.GaussianBlur(height * 0.06))
    img.paste(blurred, mask=mask)
    return img

    @classmethod
    def __add_img_txt(cls, img, draw, txt: str, top_margins: List[int]):
    top_margin = top_margins[0]
    myFont = ImageFont.truetype("SFNSMono.ttf", int(0.14 * img.size[0]))
    txt_size = draw.textsize(txt, font=myFont)
    versionTxtO = ((img.size[0] - txt_size[0]) / 2, top_margin)
    draw.text(versionTxtO, txt, fill="black", font=myFont)
    top_margins[0] = top_margin + txt_size[1]
    return img

    def add_single_img(self, img_path: str):
    img = Image.open(img_path)
    blur_rect = (0, int(img.size[1]*0.5),
    int(img.size[0]), int(img.size[1]*0.5))
    img = AddVersionInfo.__add_img_blur(img, blur_rect)

    draw = ImageDraw.Draw(img)
    last_top_margin = img.size[1]*0.5 + 2
    for txt in [self.version, self.branch_name, self.commit_id]:
    margin_wrapper = [last_top_margin]
    img = AddVersionInfo.__add_img_txt(
    img, draw, txt, top_margins=margin_wrapper)
    last_top_margin = margin_wrapper[0]
    img.save(img_path)

    def add_version_info(self):
    print("版本号:{}".format(self.version))
    print("分支:{}".format(self.branch_name))
    print("上次提交:{}".format(self.commit_id))
    for root, _, file_list in os.walk(self.img_folder):
    for file in file_list:
    full_path = os.path.join(root, file)
    if os.path.isfile(full_path) and imghdr.what(full_path) in ['png']:
    self.add_single_img(full_path)

    预期是给 .appiconset 中所有图片加上指定文字信息。先在图片的下半部分添加模糊效果,避免文字看不清,接着在模糊部分从上至下绘制文本,保存。由于图片大小不一,所以选择了纤细、清晰的系统字体,并且根据图片大小决定模糊程度和文字字号。

  5. 运行与部署

    • 加上依赖:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      from PIL import Image, ImageFilter, ImageDraw, ImageFont
      import sys
      import imghdr
      from pathlib import Path
      import typing
      import plistlib
      from typing import List
      import subprocess
      from pbxproj import PBXNativeTarget
      from pbxproj import XcodeProject
      import os
    • 完善入口:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      if __name__ == '__main__':

      pathes = sys.argv[1:] if len(sys.argv) > 1 else []
      if len(pathes) != 2:
      print("参数个数错误")
      else:
      pbproj_filepath = os.path.abspath(pathes[0])
      target_name = pathes[1]
      if not Path(pbproj_filepath).exists():
      print("FATAL:{} 不存在".format(pbproj_filepath))
      exit(1)
      pf = ProjectInfo(pbproj_filepath, target_name)
      avi = AddVersionInfo(pf.get_icon_image_folder(),
      pf.get_version_info(),
      pf.get_git_branch_name(),
      pf.get_git_last_cmt_id())
      avi.add_version_info()
    • 部署
      在 Jenkins 的 Execute shell 中加上调用语句:

      1
      python3 ./ScriptsProvisoningfiles/add_logo.py ./MachOExploration.xcodeproj/project.pbxproj MachOExploration

      然后再执行构建动作。

后续

运行 Jenkins 的垃圾桶的算力充足,所以并没有考虑缓存,就每次构建内测包时都版本化处理 AppIcon。如果构建给 Apple,就去掉对 add_logo.py 的调用。也可以作为 Build Phrase 添加到 Xcode 中,实现 Xcode build 时自动执行,但是这时候就要考虑 AppIcon 的复用和缓存问题了。

目前脚本还不够智能,依赖的一大堆库只能手动安装,后续可以优化为自动检查和安装。

完整脚本
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
# !/usr/local/bin/python3
# -*- coding: utf-8 -*-
#

__doc__ = """

"""
# pip3 install Pillow --user
from PIL import Image, ImageFilter, ImageDraw, ImageFont
import sys
import imghdr
from pathlib import Path
import typing
import plistlib
from typing import List
import subprocess
from pbxproj import PBXNativeTarget
from pbxproj import XcodeProject
import os


class AddVersionInfo():

def __init__(self, img_folder: str, version: str, branch_name: str, commit_id: str):
self.img_folder = img_folder
self.version = version
self.branch_name = branch_name
self.commit_id = commit_id

@classmethod
def __add_img_blur(cls, img, blur_rect: tuple):
img = img.convert("RGB")
img.load()
mask = Image.new('L', img.size, 0)
draw = ImageDraw.Draw(mask)
# 左上角点,右下角点
draw.rectangle([blur_rect[:2], img.size], fill=255)
height = img.size[0]
blurred = img.filter(ImageFilter.GaussianBlur(height * 0.06))
img.paste(blurred, mask=mask)
return img

@classmethod
def __add_img_txt(cls, img, draw, txt: str, top_margins: List[int]):
top_margin = top_margins[0]
myFont = ImageFont.truetype("SFNSMono.ttf", int(0.14 * img.size[0]))
txt_size = draw.textsize(txt, font=myFont)
versionTxtO = ((img.size[0] - txt_size[0]) / 2, top_margin)
draw.text(versionTxtO, txt, fill="black", font=myFont)
top_margins[0] = top_margin + txt_size[1]
return img

def add_single_img(self, img_path: str):
img = Image.open(img_path)
blur_rect = (0, int(img.size[1]*0.5),
int(img.size[0]), int(img.size[1]*0.5))
img = AddVersionInfo.__add_img_blur(img, blur_rect)

draw = ImageDraw.Draw(img)
last_top_margin = img.size[1]*0.5 + 2
for txt in [self.version, self.branch_name, self.commit_id]:
margin_wrapper = [last_top_margin]
img = AddVersionInfo.__add_img_txt(
img, draw, txt, top_margins=margin_wrapper)
last_top_margin = margin_wrapper[0]
img.save(img_path)

def add_version_info(self):
print("版本号:{}".format(self.version))
print("分支:{}".format(self.branch_name))
print("上次提交:{}".format(self.commit_id))
for root, _, file_list in os.walk(self.img_folder):
for file in file_list:
full_path = os.path.join(root, file)
if os.path.isfile(full_path) and imghdr.what(full_path) in ['png']:
self.add_single_img(full_path)


def exe_command(list):
result = subprocess.run(list, stdout=subprocess.PIPE)
return result.stdout.decode("utf-8").strip('\n')


class Getoutofloop(Exception):
pass


class ProjectInfo():
def __init__(self, pbproject_path: str, target_name: str):
self.pbproject_path = pbproject_path
self.target_name = target_name
self.project = XcodeProject.load(pbproject_path)
self.build_configs = self.project.objects.get_configurations_on_targets(
target_name=target_name)
self.release_build_config = [
b for b in self.build_configs if b.name == 'Release'][-1]

def __find_icon_img_folder(self, icon_img_name: str, target_folder: str):
icon_img_path = ''
try:
for root, folder_list, _ in os.walk(target_folder):
for file in folder_list:
if file == icon_img_name:
icon_img_path = os.path.join(root, file)
raise Getoutofloop()
except Getoutofloop:
pass

return icon_img_path

def __project_main_path(self):
return os.path.dirname(os.path.split(self.pbproject_path)[0])

def get_icon_image_folder(self):
name = self.release_build_config.buildSettings['ASSETCATALOG_COMPILER_APPICON_NAME']
if name:
search_folder = self.__project_main_path()
icon_img_name = name+'.appiconset'
return self.__find_icon_img_folder(icon_img_name, search_folder)
print('FATAL: AppICON 路径未找到')
return ''

def __get_info_plist_path(self):
info_plist_path = self.release_build_config.buildSettings['INFOPLIST_FILE']
if info_plist_path:
xcode_placeholder_path = '$(SRCROOT)' # 可能在路径中并没有
info_plist_path = info_plist_path.replace(
xcode_placeholder_path, '')
info_plist_path = info_plist_path if info_plist_path[0] != '/' else info_plist_path[1:]
info_plist_path = os.path.join(
self.__project_main_path(), info_plist_path)
return info_plist_path

def get_version_info(self):
# 先从 project 获取,如果失败(失败定义:不全是 数字 和 '.' 组成),从 plist 获取
def valid_version(main_version: str):
"""
如果由 . 和 数字组成,就是合法的;如果全部由数字组成,也是合法的
"""
return ('.' in main_version and main_version.replace('.', '').isdigit()) or main_version.isdigit()
# 主包的主工程版本号读取
main_version = self.release_build_config.buildSettings['MARKETING_VERSION']
if main_version and valid_version(main_version):
plist_path = self.__get_info_plist_path()
print("转向从 plist 读取版本号: {}".format(plist_path))
plist = None
with open(plist_path, 'rb') as rbf:
plist = plistlib.load(rbf)
if plist:
return main_version+'.'+plist['CFBundleVersion'].split('.')[-1]
return ''

def get_git_branch_name(self):
name = exe_command(['git', 'symbolic-ref', '--short', '-q', 'HEAD']).split('/')[-1]
if len(name) == 0 or len(name.replace(' ','')) == 0:
return os.environ.get('GIT_BRANCH','').split('/')[-1]
return name

def get_git_last_cmt_id(self):
return exe_command(['git', 'rev-parse', '--short', 'HEAD'])


if __name__ == '__main__':

pathes = sys.argv[1:] if len(sys.argv) > 1 else []
if len(pathes) != 2:
print("参数个数错误")
else:
pbproj_filepath = os.path.abspath(pathes[0])
target_name = pathes[1]
if not Path(pbproj_filepath).exists():
print("FATAL:{} 不存在".format(pbproj_filepath))
exit(1)
pf = ProjectInfo(pbproj_filepath, target_name)
avi = AddVersionInfo(pf.get_icon_image_folder(),
pf.get_version_info(),
pf.get_git_branch_name(),
pf.get_git_last_cmt_id())
avi.add_version_info()
  1. Mac Pro