背景
Qt是一个跨平台开发框架,业界也有不少成熟产品基于该技术,它的好处在于一套代码即可产出各个端(mac、windows、linux)的安装包,极大的节省了开发成本。由于Qt的跨平台特性,传统native的构建技术可能就不完全适用了,基于这个背景,需要探索出基于Qt平台的构建技术。如下为mac平台构建技术的探索过程记录。
构建Mac平台包
- mac安装包的目录结构
它的标准目录结构图如下,其中MacOs(对应着可执行文件)、_CodeSignature(对应签名)、Info.plist(App相关信息)、PkgInfo、Resources这些都是必备文件,其它文件和文件夹(Frameworks等等)则根据需要创建。
- mac平台下非app store下release安装包的构建过程
对于基于Xcode工程的项目,通过xcodebuild命令完成编译过程,而对于qt项目,由于它以xxx.pro(基于qmake)或者CMakeLists.txt(基于Cmake)来组织工程文件目录结构,所以需要通过Cmake或者qmake工具来进行编译,官方现在推荐Cmake方式,而且Windows平台下CMake也更加友好写,所以这里选择CMake方式。
1、设置CMake相关环境变量
export PATH="/Users/xxxxxx/qt/Tools/CMake/CMake.app/Contents/bin:$PATH"
export PATH="/Users/xxxxx/qt/Tools/Ninja:$PATH"
2、配置CMakeLists.txt
Mac应用对于引用的三方动态库采取的策略是集成到安装包的Frameworks文件夹下,CMake默认情况下对于这些动态库的链接是绝对路径方式(通过otool -L命令可以查看),所以还需要改成相对路径的形式(通过install_name_tool 命令),xcodebuild编译时自动完成这个过程,Qt官方也提供了一个脚本自动完成这一过程。只需要在CMakeLists.txt最后添加如下代码即可
install(TARGETS QTTest
BUNDLE DESTINATION .
LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
)
qt_generate_deploy_app_script(
TARGET QTTest
OUTPUT_SCRIPT deploy_script
NO_UNSUPPORTED_PLATFORM_ERROR
)
install(SCRIPT ${deploy_script})
if(QT_VERSION_MAJOR EQUAL 6)
qt_finalize_executable(QTTest)
endif()
3、编译
mkdir build
cmake -DCMAKE_PREFIX_PATH=/Users/xxxxx/qt/6.5.3/macos -S ./ -B ./build -G Ninja
cd build
ninja
4、签名公证及验证
调用codesign指令,签名成功后还需要去发给苹果公证,公证完成后才可以放在互联网上分发。具体的公证及dmg生成过程可以参考
# 签名
codesign --force --deep --verbose --options=runtime --sign "证书名称(钥匙串中)" xxx.app路径
# 验证签名
codesign --verify --deep --strict --verbose=2 xxx.app路径
# 将app打包为zip包
/usr/bin/ditto -c -k --sequesterRsrc --keepParent xxx.app路径 xxx.zip路径
# 进行公证
xcrun notarytool submit "xxx.zip路径" --keychain-profile "公证专用秘钥" --wait
/usr/bin/ditto -x -k "${xxx.zip}" ./
# 验证公证结果
xcrun stapler validate xxx.app路径
# 生成dmg
python3 create_dmg.py "xxx.app" "dmg路径"
完整构建脚本
#!/usr/bin/env bash
version="2.0.4"
qt_lib_path=/Users/zhuangshanzhi/qt/6.5.3/macos
qt_cmake_path=/Users/zhuangshanzhi/qt/Tools/CMake/CMake.app/Contents/bin
qt_ninja_path=/Users/zhuangshanzhi/qt/Tools/Ninja
current_path="$(pwd)"
build_result="${current_path}/BuildResult"
parent_path=$(dirname "$current_path")
app_name=$(cat ../CMakeLists.txt |grep -i 'Project(' | sed 's|.*(\([a-zA-Z0-9]*\)\ .*|\1|g')
buildabs_mac="/usr/local/bin/buildabs_mac"
new_build_version=""
build_type="test"
input_path=""
output_path=""
param_count=0
verbose=0
need_notray=0
mark_dmg=0
profile="MytestApp profile"
channels=""
valid_args=(-new -input -output -notary -dmg -profile -channels -version -verbose -help)
# 打印帮助
function print_help_and_exit() {
echo "-环境参数: test、developerid、release,固定为第一个参数"
echo "-new: 指定编译版本号,会同步更改 Xcode 工程中的编译版本号"
echo "-input: .app 文件路径,默认是当前工程目录下的 /Buider/BuildResult 目录"
echo "-output: dmg 包输出路径,默认是当前工程目录下的 /Buider/BuidResult 目录"
echo "-notary: 标记需要公证,无需携带参数。(环参数为:developerid、release 时默认会公证)"
echo "-dmg: 标记需要生成dmg,无需携带参数。(环参数为:developerid、release 时默认会生成)"
echo "-profile: 自定义用于公证的 keychain-profile 名称,默认:MytestApp profile"
echo "-channels: 渠道号,多个渠道用英文的逗号隔开,eg.: -channels \"web,ab123\""
echo "-version: 查看脚本的版本号"
echo "-verbose 提供的详细状态输出"
echo "-help: 查看帮助"
echo ""
}
invalid_param() {
# 传了无效参数
echo "⚠️ 参数无效⚠️ :"
print_help_and_exit
exit 1
}
# 用于控制加载动画是否继续运行的变量
is_loading=false
# 加载动画函数
loading_animation() {
chars="/-\|"
while :; do
for ((i = 0; i < ${#chars}; i++)); do
echo -ne "\b$1${chars:$i:1}"
sleep 0.1
done
done
}
loading_animation_1015() {
chars=('|' '/' '-' '\')
idx=0
while true; do
printf '\r%s' "$1${chars[idx]}"
sleep 0.1
((idx = (idx + 1) % ${#chars[@]}))
done
}
stop_animation() {
is_loading=false
kill $loading_pid
wait $loading_pid 2>/dev/null
printf "\r"
}
kill_animation() {
stop_animation
exit 0
}
# 启动加载动画
begin_load_animation() {
is_loading=true
trap kill_animation SIGINT
loading_animation &
loading_pid=$!
}
# 生成快捷方式
if [ ! -f "$buildabs_mac" ]; then
sudo ln -s "${current_path}/buildabs_mac" "$buildabs_mac"
sudo chmod +x "$buildabs_mac"
echo "生成快捷方式成功,后续可直接使用:\`buildabs_mac [action]\`, eg. buildabs_mac test"
fi
# 判断第一个参数是否为 "test", "developerid" 或 "release"
if [ "$1" == "test" ] || [ "$1" == "developerid" ] || [ "$1" == "release" ]; then
build_type="$1"
shift # 移除已处理的第一个参数
else
if [[ $1 =~ ^- ]]; then
found=0
for valid_arg in "${valid_args[@]}"; do
if [ "$1" == "$valid_arg" ]; then
found=1
break
fi
done
if [ $found -eq 0 ]; then
invalid_param
fi
else
invalid_param
fi
fi
# 遍历所有参数
while (("$#")); do
# 当前参数以 "-" 开头,表示这是一个选项
if [[ $1 == -* ]]; then
option_name="$1"
case "$option_name" in
-new)
# if [[ -n "$2" ]] && [[ $2 != -* ]]; then
new_build_version="$2"
shift 2
;;
-input)
input_path="$2"
shift 2
;;
-output)
output_path="$2"
shift 2
;;
-notary)
need_notray=1
shift
;;
-dmg)
mark_dmg=1
shift
;;
-profile)
profile="$2"
shift 2
;;
-channels)
channels="$2"
shift 2
;;
-verbose)
verbose=1
shift
;;
-help)
print_help_and_exit
exit 0
;;
-version)
echo "$version"
exit 0
;;
*)
invalid_param
print_help_and_exit
exit 1
;;
esac
else
invalid_param
print_help_and_exit
exit 1
fi
done
read_default_channel_id() {
# 获取上层目录路径
parent_directory="$(dirname "$build_result")"
grandparent_directory="$(dirname "$parent_directory")"
plist_file="$grandparent_directory/MytestApp/MytestApp-Info.plist"
channels=$(defaults read "${plist_file}" "Channel")
}
stepup_workspace() {
echo "➤➤➤ "
sub_build_result="$build_result/$new_version"
if [ ! -d "$build_result" ]; then
mkdir -p "${build_result}"
fi
echo "工作目录:$current_path"
# 删除,如果存在
if [ -d "$sub_build_result" ]; then
rm -r $sub_build_result
echo "目录已存在 -> 删除"
fi
# 创建目录
if [ ! -d "$sub_build_result" ]; then
mkdir -p "${sub_build_result}"
echo "创建目录: ${sub_build_result}"
fi
output_path=$sub_build_result
}
buuild_app_if_need() {
if [ -d "$parent_path/build" ]; then
rm -rf ../build
fi
mkdir -p $parent_path/build
export PATH="$qt_cmake_path:$PATH"
export PATH="$qt_ninja_path:$PATH"
# 如果没有传入 App,则编译 App
if [ -z "$input_path" ]; then
echo "➤➤➤"
echo "开始编译 App"
echo "编译日志:${log_path}"
echo -n "编译ing......"
begin_load_animation
cmake -DCMAKE_PREFIX_PATH=$qt_lib_path -DCMAKE_INSTALL_PREFIX=../build -S ../ -B ../build -G Ninja
cd ../build
ninja
ninja install
cd -
stop_animation
input_path="$output_path/${app_name}.app"
cp -R $parent_path/build/${app_name}.app "$output_path"
if [ -e "$input_path" ]; then
echo "编译完成✅ : $input_path"
else
echo "编译失败❌ :,请查看日志。"
exit 1
fi
fi
}
change_channel_id_in_project() {
echo "➤➤➤"
echo "更改渠道号: $1"
defaults write "$input_path/Contents/Info.plist" Channel -string "$1"
read user_input
}
change_channel_id_in_app_if_need() {
echo "➤➤➤"
echo "核对渠道号"
old_channel=$(defaults read "$input_path/Contents/Info.plist" Channel)
if [ "$1" = "$old_channel" ]; then
echo "渠道号一致,无需更改: $1"
return 1
else
echo "更改渠道号: $1"
defaults write "$input_path/Contents/Info.plist" Channel -string "$1"
return 0
fi
}
copy_app() {
echo "➤➤➤"
echo "拷贝 App......t"
echo "input_path: $input_path"
echo "output_path: $output_path"
echo "zip_output: $zip_output"
begin_load_animation
# /usr/bin/ditto -x -k "$zip_output" "$output_path"
cp -R "$input_path" "$output_path"
stop_animation
input_path=$output_path/$app_name
}
recodesign() {
echo "删除历史签名"
codesign --remove-signature "$input_path"
echo "重签名"
# codesign --force --deep --verbose --options=runtime --sign "Pixocial Technology (Singapore) Pte Ltd (5V292QZ538)" "$input_path"
codesign --force --deep --options runtime --timestamp --verbose=2 --sign "Pixocial Technology (Singapore) Pte Ltd (5V292QZ538)" "$input_path"
}
create_zip() {
# 创建 zip 名称
name=$(echo $app_name | sed 's/[ ][ ]*/_/g')
project_version=$(/usr/libexec/PlistBuddy -c "Print CFBundleVersion" $input_path/Contents/Info.plist)
if [ "$build_type" == "test" ]; then
zip_app_name="${name}_${project_version}($project_build_version)_universal_unsigned"
elif [ "$build_type" == "release" ]; then
zip_app_name="${name}_${project_version}_universal"
else
zip_app_name="${name}_${project_version}($project_build_version)_universal"
fi
}
verify_codesign() {
echo "➤➤➤"
echo "验证签名"
codesign --verify --deep --strict --verbose=2 "${input_path}"
# --verbose=2(在这里是 2)表示日志级别。该参数值可以为 0、1、2 或 3。具体含义如下:
# 0:仅显示错误消息
# 1:显示错误和警告消息
# 2:显示所有校验过程中的信息,包括详细的签名内容
# 3:显示额外的调试信息
# 确认是否需要公证及生成 dmg
if [ "$build_type" != "test" ]; then
need_notray=1
mark_dmg=1
else
open $output_path
fi
}
app_to_zip() {
# 压缩 zip
echo "➤➤➤"
echo -n "压缩 zip......"
begin_load_animation
cd ${output_path} || exit
zip_output="${output_path}/$zip_app_name.zip"
/usr/bin/ditto -c -k --sequesterRsrc --keepParent "$input_path" "$zip_output"
stop_animation
if [ -e "${zip_output}" ]; then
echo "压缩完成✅: $zip_output"
else
echo "压缩失败❌"
exit 1
fi
}
replace_zip() {
rm -r "$zip_output"
/usr/bin/ditto -c -k --keepParent "$input_path" "$zip_output"
}
notary_app() {
# 公证
if [ $need_notray -eq 1 ]; then
echo "➤➤➤"
echo "开始公证......"
rm -r "$input_path"
xcrun notarytool submit "${zip_output}" --keychain-profile "${profile}" --wait
/usr/bin/ditto -x -k "${zip_output}" ./
validate_result=$(xcrun stapler staple "${input_path}")
xcrun stapler validate "${input_path}"
if [[ "${validate_result}" =~ "The staple and validate action worked" ]]; then
echo '公证成功✅'
replace_zip &
else
echo '公证失败!❌'
# xcrun notarytool info "3b76bc86-7245-4735-97dd-09ca2f1c4e59" --keychain-profile "MytestApp profile"
# xcrun notarytool log "3b76bc86-7245-4735-97dd-09ca2f1c4e59" --keychain-profile "MytestApp profile"
exit 1
fi
fi
}
make_dmg() {
# 生成 DMG
if [ $mark_dmg -eq 1 ]; then
cd "$current_path" || exit
dmg_output_path="${output_path}/$zip_app_name.dmg"
echo "➤➤➤"
echo "生成 DMG......"
echo "input: $input_path"
echo "output: $dmg_output_path"
if [ $verbose -eq 1 ]; then
dmg_log_path="$output_path/MytestApp_make_dmg.log"
python3 create_dmg.py "$input_path" "$dmg_output_path" >"$dmg_log_path" 2>&1
else
begin_load_animation
python3 create_dmg.py "$input_path" "$dmg_output_path" >/dev/null 2>&1
stop_animation
fi
if [ -f "$dmg_output_path" ]; then
echo '生成 dmg 包成功✅:'
echo "$dmg_output_path"
open $output_path
else
echo '生成 dmg 包失败!❌'
exit 1
fi
fi
}
if [ -z "$channels" ]; then
# 读取默认的渠道号
read_default_channel_id
fi
#设置输出目录
stepup_workspace
#1、编译App
buuild_app_if_need
#2、签名
recodesign
#3、验证签名
verify_codesign
create_zip
app_to_zip
#4、公证
notary_app
make_dmg "$element"
if [ -e "$zip_output" ]; then
open "${sub_build_result}"
fi
exit 0
最后目录如下:
脚本下载地址:
https://gitee.com/nldxrz/ffmpeg-build-scripts/tree/master/qt