iOS话外篇:构建详解

Posted by PaysonChen on August 8, 2023

iOS话外篇:构建详解

1.背景

想必大多数iOS开发者不陌生,通过xcode 自带的 archive 打包,习惯GUI的同学,可能更倾向于archive打包,虽然archive打包有很多优点:包括可视化、学习门槛低、报错清晰等,但是在团队协作与日常构建中,他的弊端就显现出来了:

  • 步骤多

​ 打包前需要同步代码、打包过程中需要选择签名、打包后要归档、并选择上传包的地方(分发渠道orAppStore)

  • 时间长

​ 构建时间随着工程迭代越来越漫长,大型全量打包可能到数十分钟,archive会阻断当前研发进程,无法利用本机算力之外的其他计算集群资源,影响研效

  • 灵活不佳

​ 构建过程中可能需要对版本的build号进行自增、归档dsym文件、构建通知等,采用archive这些都可能需要手动执行

  • 可靠性不佳

​ 对于分支、代码、签名、构建方式等匹配均需靠人工维护,流程步骤多,容易造成错误,不可靠

2.构建设计

为了设计一个通用的构建脚本,需要将核心构建流程分层与归类,设计入参、使用构建环境等。程序入口如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
#通过外部传参 读取自定义参数 2:target_name
# 2 必须
coustomSettings $2

# 3 (可选) 蒲公英渠道号,拼接url
publishSettings $3

# 4 (可选)是否静默通知,有值 则不发通知
notificationSettings $4

#开始构建 1:configuration(0:DailyBuild|1:TestFlight|2:Release|3:TFInner)
#1 必须
build $1

2.1入参设计:

2.1.1参数1:构建方式

对应工程中的Configuration ,枚举值:0:DailyBuild 1:TestFlight 2:Release 3:TFInner
  • DailyBuild:用于日常构建,CI/CD

  • TestFlight:用于打灰度包

  • Release:用于打生产包

  • TFInner:用于打内部TF包

    特别说明:由于,Apple的Enterprise证书($299/年)账号申请条件苛刻,通过此方案可以临时解决100台测试设备不足的问题

2.1.2 参数2:Target名称

工程的TargetName:

iOS_Build1

2.1.3 参数3(可选):渠道号

​ 在没有使用支持iOS app分发的流水线(类似于腾讯蓝鲸(蓝盾流水线))情况下:本文例子使用蒲公英进行应用分发,为了兼容多渠道并行,用于拼接分发下载地址的url。

iOS_Build2

2.1.3.1 第三方分发:蒲公英

​ 流水线通过API进行应用分发,下面说明,渠道号为空与非空的情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
  #其他构建包上传到pgy
        echo '/// 上传 PGY 开始 IPA_PATH='${IPA_PATH}
        echo '/// 上传 PGY 开始 渠道='${pgyChannel}
        compileEnvironment="${buildName}:${git_log}"
        
        if [ -z "$pgyChannel" ]; then
            # 渠道空,则走默认 不指定渠道号的上传
            curl -F 'file=@'${IPA_PATH} -F '_api_key='${pgyAPIKey} -F 'buildUpdateDescription='${compileEnvironment} https://www.pgyer.com/apiv2/app/upload
        else
            # 渠道非空,走下载地址关联 渠道
            curl -F 'file=@'${IPA_PATH} -F '_api_key='${pgyAPIKey} -F 'buildUpdateDescription='${compileEnvironment}  -F 'buildChannelShortcut='${pgyDownloadUrlSubfix} https://www.pgyer.com/apiv2/app/upload
        fi
                
        echo '/// 上传 PGY 结束'
2.1.3.2 扩展:阿里云OSS云存储分发

基于蒲公英的分发受限于蒲公英上行带宽与收费阈值,探索一种容灾分发方式,避免蒲公英未来服务出现异常时的健壮性。

自行分发需要用Ad-Hoc方式,遵循Apple协议:itms-services://?action=download-manifest&url=

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
#添加commit信息到二维码图片上
function add_log_commit_title_msg_to_qrcode ()
{
    #二维码打上版本信息
    #TODO: 这里需要安装下工具 ImageMgick [brew install ImageMgick]
    title=$xcodeVersion3number.${channel_name}.$configurationName.${BuildNo}

    if [ $1 = "1" ]; then
        dst_line_cnt=0
        export oss_source_log_arr=(`echo $source_log_str | tr '][' ' '`)
        export oss_log_line_count=${#oss_source_log_arr[@]}
        echo '/// oss 分发: 添加 二维码msg log_line_count='$oss_log_line_count

        for(( i=0;i<${oss_log_line_count};i++))
        do
            single_len=$(echo -n "${oss_source_log_arr[i]}" | wc -m)
            if [ -z "${oss_source_log_arr[i]}" ]; then
                oss_singleStr=${oss_source_log_arr[i]};
                echo '///--遍历'${oss_source_log_arr[i]}'为空文字长度:'$single_len
                dst_line_cnt=$((dst_line_cnt+1))
            else
                line_max_len=30
                ingle_cnt=$((single_len / line_max_len))


                if ((ingle_cnt > 0)); then
                    for(( j=0;j<=${ingle_cnt};j++))
                    do
                        string=${oss_source_log_arr[i]}
                        single_line1=${string:(line_max_len * j):line_max_len}
                        echo '///--遍历' ${oss_source_log_arr[i]}'文字长度:'$single_len'超阈值阶段:第'$j'行:'$single_line1
                        oss_singleStr+="\n"${single_line1};
                        dst_line_cnt=$((dst_line_cnt+1))
                    done;
                else
                    echo '///--遍历' ${oss_source_log_arr[i]}' 文字长度:'$single_len
                    dst_line_cnt=$((dst_line_cnt+1))
                    oss_singleStr+="\n"${oss_source_log_arr[i]};
                fi
            fi
        done;
        msg=$oss_singleStr
        echo "/// oss render msg="$msg
        
        contentsize_width=600
        contentsize_height=$((contentsize_width + $dst_line_cnt * 25 + 40 + 40)) #第一个40是title的高度 第二个40是距离底部空间
        echo "contentsize_height="$contentsize_height
        #改变原图大小
        convert $qrcode_path -resize ${contentsize_width}x${contentsize_width}^ -gravity center ${qrcode_path}_1.png

        #变高,兼容msg
        convert ${qrcode_path}_1.png -gravity north -extent ${contentsize_width}x${contentsize_height} ${qrcode_path}_2.png

        #汉字字体
        chn_tff_path=$build_ext_path/publish_res/chn.ttf
        
        #渲染title
        convert ${qrcode_path}_2.png -gravity north -pointsize 30 -font $chn_tff_path -fill black -annotate +0+${contentsize_width} $title ${qrcode_path}_3.png

        #渲染msg
        convert ${qrcode_path}_3.png -gravity north -pointsize 20 -font $chn_tff_path -fill black -annotate +0+$((${contentsize_width}+40)) $msg ${qrcode_path}_4.png
        qrcode_path_with_msg=${qrcode_path}_4.png
    else
        #没有传 1 不需要msg,只给title
        convert $qrcode_path -gravity south -pointsize 10 -fill gray -annotate +0+0 $qrcode_print_msg  $qrcode_path_with_msg
        echo '/// oss 分发: 添加 二维码title='${title}
    fi

}

#发布到阿里云OSS
function upload_ipa_to_aliyunOSS ()
{
    #更新plist的键值对:
    manifest_path=${exportPath}/manifest.plist
    echo '/// oss 分发: manifest_path='${manifest_path}
    
    oss_bucket="cbs-apps-test"
    oss_platform="iOS"
    oss_bucket_path=$oss_platform/${TARGET_NAME}/$configurationName/${channel_name}/${BuildNo}/${TARGET_NAME}_${channel_name}_$configurationName_${BuildNo}
    oss_path="oss://$oss_bucket/$oss_bucket_path"
    oss_url="https://$oss_bucket.oss-cn-beijing.aliyuncs.com/$oss_bucket_path"

    oss_ipa_upload_url=$oss_path".ipa"
    oss_ipa_url=$oss_url".ipa"

    #icon下载地址配置
    oss_icon_url="https://cbs-apps-test.oss-cn-beijing.aliyuncs.com/iOS/${TARGET_NAME}/icon/icon.png"
    oss_icon_fs_url="https://cbs-apps-test.oss-cn-beijing.aliyuncs.com/iOS/${TARGET_NAME}/icon/icon_fs.png"

    
    #修改ipa下载地址
    /usr/libexec/PlistBuddy -c "Set :items:0:assets:0:url $oss_ipa_url" $manifest_path

    #修改icon地址
    /usr/libexec/PlistBuddy -c "Set :items:0:assets:1:url $oss_icon_url" $manifest_path

    #修改icon full size地址
    /usr/libexec/PlistBuddy -c "Set :items:0:assets:2:url $oss_icon_fs_url" $manifest_path


    #传ipa
    ossutil cp ${IPA_PATH} $oss_ipa_upload_url -f
    
    #传manifest
    oss_manifest_extension="plist"
    oss_manifest_upload_url=$oss_path"."$oss_manifest_extension
    oss_manifest_url=$oss_url"."$oss_manifest_extension
    ossutil cp ${manifest_path} $oss_manifest_upload_url -f
    
    #上述做归档后,生成下载二维码
    download_url="itms-services://?action=download-manifest&url="$oss_manifest_url
    qrcode_path=${exportPath}/${TARGET_NAME}_${BuildNo}.png
    
    #TODO: 这里需要安装下工具 qrencode [brew install qrencode]
    qrencode -o $qrcode_path $download_url
    
    #将二维码上传到oss
    oss_qrencode_extension="png"
    oss_qrencode_upload_url=$oss_path"."$oss_qrencode_extension
    qrcode_path_with_msg=${qrcode_path}_with_msg
        
    #如果需要二维码打上log信息则调用
    need_msg=1
    add_log_commit_title_msg_to_qrcode $need_msg
        
    #上传到阿里云oss
    ossutil cp ${qrcode_path_with_msg} $oss_qrencode_upload_url -f
    
    #二维码的url 通知出来
    export oss_app_download_qrcode_url=$oss_url"."$oss_qrencode_extension
    echo '/// oss 分发: 二维码的oss_app_download_qrcode_url='${oss_app_download_qrcode_url}
}

2.1.4 参数4(可选):是否静默通知

​ 本文例子通过钉钉API发送通知到钉钉群,默认不填发送,有值则不发通知,通知内容整合双分发方案:

​ 下面代码为发送通知的逻辑,请将相应的变量替换成自己的参数

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
    modify_msg=${git_log}
    ProductName=${xcodeProductName}
    downloadPrefix=${PrefixID}
    channel=${pgyChannel}
    title=${buildName}

    #采用直接 拼 channel后缀
    shortUrl=${downloadPrefix}${ProductName}${channel}

    #蒲公英下载地址
    pgy_app_donwnload_url="https://www.pgyer.com/${shortUrl}"
    
   #oss下载地址
    if [ -z "$oss_app_download_qrcode_url" ]; then
        #为空则使用pgy
        echo "通知:oss_app_download_qrcode_url 为空则使用pgy:'${app_donwnload_url}'"
        app_donwnload_url=$pgy_app_donwnload_url
    else
        #不为空使用 pgy + oss
        echo "通知:oss_app_download_qrcode_url 不为空使用 pgy + oss则使用pgy + oss:'${app_donwnload_url}' and '$oss_app_download_qrcode_url'"
        app_donwnload_url=${pgy_app_donwnload_url}'\n'oss下载地址:$oss_app_download_qrcode_url
    fi
#    app_donwnload_url=$oss_app_download_qrcode_url
    
    echo "通知 COME下载地址:'${app_donwnload_url}'"

    
    curl 'https://oapi.dingtalk.com/robot/send?access_token='${access_token} \
        -H 'Content-Type: application/json' \
        -d '{"msgtype": "text",
            "at": {
            "atMobiles":[
                "这"
                "里"
                "是"
                "钉"
                "钉"
                "账"
                "号"
            ],
            "isAtAll": false
            },
             "text": {
                  "content": "'${ProductName}' App更新:'${title}'\n'${modify_msg}'\n下载地址:https://www.pgyer.com/'${app_donwnload_url}'"            }
           }'
    echo '///-------------------------------------------------------'
    echo '/// 发送通知 '
    echo '///-------------------------------------------------------'

2.2 流程设计:

2.2.1 构建前:

  • 入参设置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#入参设置
function coustomSettings()
{
    #需要打包的target
    if [ -z "$1" ]; then
        echo "error: TARGET_NAME 不能为空 退出"
        exit 1
    else
        export TARGET_NAME=$1
    fi

    #工程所在目录,如果在根目录则为${WORKSPACE}
    
    if [ -z "$2" ]; then
        export projectDir=${TARGET_NAME}
    else
        export projectDir=$2
    fi
#    export projectDir=YPCProject
    echo "TARGET_NAME="$TARGET_NAME
    echo "projectDir="$projectDir

}
  • 发布设置:
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
#发布及归档的相关的设置
function publishSettings()
{
    if [ -z "$1" ]; then
        echo '///渠道号入参 为空'
        export pgyChannel=Dev
    else
        echo '///渠道号入参 不为空'
        export pgyChannel=$1
    fi
    
    echo "////渠道号"${pgyChannel}
    
    #声明蒲公英分发调用APi的pgyAPIKey
    export pgyAPIKey="pgyAPIKey"
    
    #钉钉发送通知机器人的access_token
    export access_token="access_token"

    #AppStore的token
    export appStore_apiIssuer="appStore_apiIssuer"
    export appStore_apikey="appStore_apikey"
            
    #bugly的token
    export bugly_appid="bugly_appid"
    export bugly_appkey="bugly_appkey"
}
  • 通知设置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
#通知相关的设置(pgy渠道及apikey)
function notificationSettings()
{
    notificationSettings=$1
    if [ ${notificationSettings} = "1" ]; then
        echo '///静默入参 不为空'
        export notificationSilent=$notificationSettings
    else
        echo '///静默入参 为空 或者不为1 不发通知'
    fi
    
    echo "////静默通知="${notificationSilent}
}

  • 参数获取:

    通过xcode工具获取工程项目自带的参数

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
#获取工程配置信息
function initInfoPlistParms()
{
    #Plist先写死。后续可考虑改成搜索
    cd ${CurDir}/${projectDir}
    versionKey="MARKETING_VERSION"
    bundleID="PRODUCT_BUNDLE_IDENTIFIER"
    bundleNo="CURRENT_PROJECT_VERSION"
    platformName="PLATFORM_NAME"
    productName="PRODUCT_NAME"
    #将要获取的key设置成数组
    buildSettingsGrepKeyArray=($versionKey $bundleID $bundleNo $platformName $productName)
    buildSettingsGrepKey=""
    for(( i=0;i<${#buildSettingsGrepKeyArray[@]};i++))
    do
        echo "【获取Plist参数】遍历 idx="$i "value=" ${buildSettingsGrepKeyArray[i]}
        if [ -z "$buildSettingsGrepKey" ]; then
            buildSettingsGrepKey=${buildSettingsGrepKeyArray[i]};
        else
            buildSettingsGrepKey+="\|"${buildSettingsGrepKeyArray[i]};
        fi
    done;
    #将数组拼接成 grep 参数
    echo "【获取Plist参数】key:"$buildSettingsGrepKey

    #-w 防止模糊匹配 比如  PRODUCT_NAME 和 FULL_PRODUCT_NAME
    buildSettings=`xcodebuild -showBuildSettings -target ${TARGET_NAME} | grep -w $buildSettingsGrepKey`
    echo "【获取Plist参数】result:"$buildSettings
    
    #取值数组
    buildSettingsGrepValueArray=[]
    for(( i=0;i<${#buildSettingsGrepKeyArray[@]};i++))
    do
        #右截断
        value=${buildSettings#*${buildSettingsGrepKeyArray[i]} = }
        for(( j=0;j<${#buildSettingsGrepKeyArray[@]};j++))
        do
            #由于grep顺序不保证,遍历所有key进行右截断
            value=${value%${buildSettingsGrepKeyArray[j]}*}
        done;
        value=`echo ${value} | sed 's/\ //g'`
        echo "【获取Plist参数】i="$i "value="$value"。"
        #填回取值数组
        buildSettingsGrepValueArray[i]=${value}
    done;
    
    #定义全部变量
    export xcodeVersion3number=${buildSettingsGrepValueArray[0]}
    echo "【获取Plist参数】版本号:"$xcodeVersion3number"。"

    export xcodeBundleID=${buildSettingsGrepValueArray[1]}
    echo "【获取Plist参数】包名:"$xcodeBundleID"。"

    export xcodebundleNo=${buildSettingsGrepValueArray[2]}
    echo "【获取Plist参数】build号:"$xcodebundleNo"。"
    
    export xcodePlatformName=${buildSettingsGrepValueArray[3]}
    echo "【获取Plist参数】PlatformName:"$xcodePlatformName"。"
    
    export xcodeProductName=${buildSettingsGrepValueArray[4]}
    echo "【获取Plist参数】ProductName:"$xcodeProductName"。"
    cd ..
}

  • 构建号设置:

​ 设置构建号,可以避免每次构建的build重复,导致在appstore后台管理构建时的紊乱,也便于追溯构建代码版本等,而如果可以通过自动化的方式进行构建号的设置就更好了,这里提供一种设置思路:获取当前日期+流水线构建号(自增),可以构建流水线内保证全局唯一:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#自动(自增)修改构建号
function modify_build_no()
{
    cd ${CurDir}/${projectDir}
    export BuildNo=`date +%Y%m%d`
    #BuildNo,可以采用拼接日期+构建方式+流水线自增号来识别构建包的来源(构建方式:dailybuild、TestFlight、Release、TFInner)
    BuildNo=${BuildNo}.$1.${SYS_PIPELINE_BUILD_NUMBER}
#    plist_path=${CurDir}/${projectDir}/${projectDir}/Info.plist
    echo "【获取Plist参数】路径:"$plist_path
    echo "【设置Plist参数】build:"$BuildNo    
#    /usr/libexec/PlistBuddy -c "Set :CFBundleVersion ${BuildNo}" ${plist_path}
    agvtool new-version -all ${BuildNo}
    cd -
}

2.2.2 构建中:

  • 执行构建:

    在完成了所有必要的构建前的设置之后,可以进行构建流程:

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
#执行构建
function build()
{
    if [ -z "$1" ]; then
        echo "error: configurationName 不能为空 退出"
        exit 1
    fi
    #定义项目前缀,生成文件时使用
    export PrefixID=PSC_

    #设置根目录
    export CurDir=`pwd`
    
    #进行打包的schemeName
    export schemeName=dailybuildipa
    
    #指定编译 配置 configuration
    configurationNameArray=(DailyBuild TestFlight Release TFInner)
    export configurationName=${configurationNameArray[$1]}

    #指定编译脚本路径
    #TODO: 这里需要匹配下当前脚本所在的目录
    export buildsh_dir=${CurDir}/PSC_iOS_Build_SH

    #设置plist参数
    modify_build_no $1

    #读取plist参数
    initInfoPlistParms
    
    #执行构建
    bash $buildsh_dir/build_sh.sh
}

构建流程设计包括:

  • 设置环境变量
  • 设置内部变量

  • 初始化环境

  • mPaas设置(可选)

  • 执行pod

  • 构建工具适配(可选)

  • 执行构建
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
function build()
{
    #设置内部环境变量
    initDevopsEnv
 
    #设置内部环境变量
    createInternalEnv
        
    #初始化环境
    init_env

    #执行pod之前,先配置mPaas在当前环境下的配置文件
    mPaaSConfigSet
    
    #执行pod
    pod_install

    #fix xcode 14.3 cocoapods bug
    fiXCode143

    #执行构建
    buildProject

    #执行构建归档操作
    collection
    
    #上传(发布)安装包,供下载安装
    bash $buildsh_dir/$build_ext_fold/$publish_sh

    if [ ${configurationName} = "DailyBuild" ]; then
        #通知
        bash $buildsh_dir/$build_ext_fold/$notification_sh
    fi
}

​ 构建具体的思路可以参考源码,这里不再赘述

2.2.3 构建后:

  • 归档

为什么要归档:

归档主要是为了对构建的安装包IPA及dSYM进行一一对应,由于dailybuild都是在团队内分发,因此这边考虑只有在testflight及appstore时才进行归档。

此外;如果使用bugly进行崩溃收集,bugly已经不支持手动传dSYM,需要在构建时一并实现。

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
function upload_dSYM()
{
		#这里可以根据各自情况,存在dSYM文件
}

function upload_dSYM_to_bugly()
{
    export p=${dSYM_dict_PATH}
    
    #根据不同的应用进行设置
    if [ ${TARGET_NAME} = "PSCiOS" ]; then
        bugly_appid=xxx
        bugly_appkey=yyy
    fi
    
    echo '/// 上传 dsym bugly '${v}'pkgName='${pkgName}
    
    #切换java 版本
    java -version
    jdk8
    java -version
    
    bugly_jar_path=$buildsh_dir/$build_ext_fold/collection_res/buglyqq-upload-symbol/buglyqq-upload-symbol.jar
    
    java -jar ${bugly_jar_path} -appid ${bugly_appid} -appkey ${bugly_appkey} -bundleid ${pkgName} -version ${v} -platform IOS -inputSymbol ${p}
}

function collection()
{
    export v=${xcodeVersion3number}.${BuildNo}
    export pkgName=${xcodeBundleID}

    #上传到bugly符号表
    upload_dSYM_to_bugly

    #上传dSYM
    upload_dSYM
}

#归档
collection

  • 发布

归档后,对安装包进行分发,分发也分dailybuild与release:

dailybuild:目前采用发布到蒲公英渠道:2.1.3 参数3(可选):渠道号

Release:发布到AppStore

1
2
3
4
5
6
7
8
    if [ ${configurationName} = "Release" ] || [ ${configurationName} = "TestFlight" ] || [ ${configurationName} = "TFInner" ]; then
           #TFInner 、TestFlight & appstore 上传到appstore
        echo '/// 上传App Store 开始'
        xcrun altool --validate-app -f  $IPA_PATH --type  ios  --apiKey $appStore_apikey --apiIssuer $appStore_apiIssuer
        xcrun altool --upload-app -f  $IPA_PATH --type  ios  --apiKey $appStore_apikey --apiIssuer $appStore_apiIssuer
        echo '/// 上传App Store 结束'
		fi

  • 通知

通知可以有很多种方式,由于笔者团队使用钉钉,所以这里举个钉钉机器人例子:2.1.4 参数4(可选):是否静默通知