#!/bin/bash # 出错即停 set -e # 自动使用 .fvmrc 中指定的 Flutter 版本(避免使用 PATH 里的系统 flutter 导致依赖冲突) if [ -f "./.fvmrc" ]; then FVM_VERSION=$(grep -o '"flutter"[[:space:]]*:[[:space:]]*"[^"]*"' .fvmrc | sed -E 's/.*"([^"]+)"$/\1/') if [ -n "$FVM_VERSION" ] && [ -x "$HOME/fvm/versions/$FVM_VERSION/bin/flutter" ]; then export PATH="$HOME/fvm/versions/$FVM_VERSION/bin:$PATH" echo "🔧 使用 FVM Flutter 版本: $FVM_VERSION" fi fi # 参数说明 if [ $# -lt 1 ]; then printf -- "Build hgdj release package.\n\n" printf -- "Usage: $0 [mode] [export_method] [export_options_plist] [sign_mode]\n" printf -- " target : a(android) | i(ios) | all\n" printf -- " mode : release(默认) | debug | profile\n" printf -- " export_method (iOS) : app-store(默认) | ad-hoc | development | enterprise\n" printf -- " export_options_plist : ExportOptions.plist 路径(iOS 可选,留空用默认)\n" printf -- " sign_mode (iOS) : codesign(默认) | nocodesign\n\n" printf -- "Examples:\n" printf -- " $0 a # Android release\n" printf -- " $0 a debug # Android debug\n" printf -- " $0 i # iOS release + app-store\n" printf -- " $0 i release ad-hoc # iOS release + ad-hoc\n" printf -- " $0 i release app-store ios/ExportOptions.plist\n" printf -- " $0 i release app-store \"\" nocodesign # iOS 不签名出包\n" printf -- " $0 all # Android + iOS release\n" exit 1 fi TARGET=$1 MODE=${2:-release} EXPORT_METHOD=${3:-app-store} EXPORT_OPTIONS_PLIST=${4:-} SIGN_MODE=${5:-codesign} # 参数校验 if [ "$TARGET" != "a" ] && [ "$TARGET" != "i" ] && [ "$TARGET" != "all" ]; then echo "❌ 参数错误:target 只支持 a | i | all,你传的是 '$TARGET'" exit 1 fi if [ "$MODE" != "release" ] && [ "$MODE" != "debug" ] && [ "$MODE" != "profile" ]; then echo "❌ 参数错误:mode 只支持 release | debug | profile,你传的是 '$MODE'" exit 1 fi case "$EXPORT_METHOD" in app-store|ad-hoc|development|enterprise) ;; *) echo "❌ 参数错误:export_method 只支持 app-store | ad-hoc | development | enterprise,你传的是 '$EXPORT_METHOD'" exit 1 ;; esac if [ "$SIGN_MODE" != "codesign" ] && [ "$SIGN_MODE" != "nocodesign" ]; then echo "❌ 参数错误:sign_mode 只支持 codesign | nocodesign,你传的是 '$SIGN_MODE'" exit 1 fi if [ -n "$EXPORT_OPTIONS_PLIST" ] && [ ! -f "$EXPORT_OPTIONS_PLIST" ]; then echo "❌ 未找到 ExportOptions.plist:$EXPORT_OPTIONS_PLIST" exit 1 fi # 项目根路径校验 if [ ! -f "./pubspec.yaml" ]; then echo "❌ 请在项目根目录下执行此脚本" exit 1 fi PROJECT_PATH=$(pwd) PUBSPEC_FILE="pubspec.yaml" # 提取项目信息 APP_NAME=$(grep '^name:' "$PUBSPEC_FILE" | awk '{print $2}') RAW_VERSION=$(grep '^version:' "$PUBSPEC_FILE" | awk '{print $2}') SAFE_VERSION=${RAW_VERSION//+/_} TIME_TAG=$(date +"%m%d-%H%M") # 混淆 + 抽离调试信息: # --obfuscate 混淆 Dart 符号;--split-debug-info 把调试信息(含 libapp.so 里 # 嵌入的本机绝对路径 .../huangguo_android/.dart_tool/.../dart_plugin_registrant.dart) # 抽到单独目录,不再残留在产物里。反编译后搜不到本机用户名/工程路径。 # 仅 release/profile 生效(debug 为 JIT,不支持也无需)。symbols 目录务必保留、勿外传, # 崩溃堆栈符号化要用它。 OBFUSCATE_ARGS="" if [ "$MODE" = "release" ] || [ "$MODE" = "profile" ]; then SYMBOLS_DIR="$(pwd)/build/symbols/${MODE}_${SAFE_VERSION}_${TIME_TAG}" mkdir -p "$SYMBOLS_DIR" # 注意:本项目已启用 BlackObfuscator,再叠加 Flutter --obfuscate 会导致启动黑屏,故关闭。 # 去除 libapp.so 内嵌构建路径改由出包后的 strip_build_path 二进制替换完成,与此无关。 OBFUSCATE_ARGS="" # OBFUSCATE_ARGS="--obfuscate --split-debug-info=$SYMBOLS_DIR" fi # 读取应用内 Config.isDebug(lib/config/config.dart),决定产物命名标识: # isDebug=true → 命名标识固定为 debug(如 hgdj_debug_xxxx),方便区分内测包 # isDebug=false → 正式环境,命名沿用构建模式(release/profile),保持不变 CONFIG_FILE="lib/config/config.dart" CONFIG_DEBUG=$(grep -E 'static[[:space:]]+const[[:space:]]+isDebug[[:space:]]*=' "$CONFIG_FILE" 2>/dev/null | grep -oE 'true|false' | head -1) if [ "$CONFIG_DEBUG" = "true" ]; then BUILD_TAG="debug" elif [ "$CONFIG_DEBUG" = "false" ]; then BUILD_TAG="$MODE" else echo "⚠️ 未能从 $CONFIG_FILE 解析 Config.isDebug,命名回退为构建模式:$MODE" BUILD_TAG="$MODE" fi DIST_DIR="./dist" mkdir -p "$DIST_DIR" # 打印信息并二次确认 echo "==========================================" echo "📦 准备打包" echo "📌 项目名: $APP_NAME" echo "📌 版本号: $RAW_VERSION" echo "📌 目标平台: $TARGET" echo "📌 构建模式: $MODE" echo "📌 Config.isDebug: ${CONFIG_DEBUG:-未知} → 命名标识: $BUILD_TAG" if [ "$TARGET" = "i" ] || [ "$TARGET" = "all" ]; then echo "📌 iOS 导出: $EXPORT_METHOD" echo "📌 iOS 签名: $SIGN_MODE" if [ -n "$EXPORT_OPTIONS_PLIST" ]; then echo "📌 ExportOptions.plist: $EXPORT_OPTIONS_PLIST" fi fi echo "📌 工程路径: $PROJECT_PATH" echo "==========================================" echo "确认无误?(按回车继续,Ctrl+C 取消)" read -r CONFIRM # 清除 libapp.so 里嵌入的本机工程绝对路径(.../<工程>/.dart_tool/.../dart_plugin_registrant.dart) # 做法:对该 URI 字符串做「等长」替换为中性路径(不含用户名/工程名),再 zipalign + 用原 keystore 重签。 # 等长替换保证 Dart String 长度字段不变、运行时安全;该 URI 仅为元数据,不参与运行逻辑。 strip_build_path() { local APK="$1" [ -f "$APK" ] || return 0 local BT; BT=$(ls -d "$HOME/Library/Android/sdk/build-tools/"* 2>/dev/null | sort -V | tail -1) # 优先用 gradle 指定的 keystore(android/keystore/app-release.jks),避免误选到同目录备份 local KS="" for cand in android/keystore/app-release.jks android/app/../keystore/app-release.jks; do [ -f "$cand" ] && { KS="$cand"; break; } done [ -z "$KS" ] && KS=$(find android -name '*.jks' ! -name '*old*' ! -name '*bak*' 2>/dev/null | head -1) local SP KP KA SP=$(grep -E "storePassword" android/app/build.gradle | head -1 | sed -E "s/.*['\"]([^'\"]+)['\"].*/\1/") KP=$(grep -E "keyPassword" android/app/build.gradle | head -1 | sed -E "s/.*['\"]([^'\"]+)['\"].*/\1/") KA=$(grep -E "keyAlias" android/app/build.gradle | head -1 | sed -E "s/.*['\"]([^'\"]+)['\"].*/\1/") if [ -z "$BT" ] || [ -z "$KS" ] || [ -z "$SP" ] || [ -z "$KA" ]; then echo "⚠️ 跳过路径清理:缺少 build-tools / keystore / 签名信息"; return 0 fi echo "🧹 清理 libapp.so 内嵌构建路径..." local TMP; TMP=$(mktemp -d) PROJ="$(pwd)" python3 - "$APK" "$TMP/patched.apk" <<'PY' import sys, os, zipfile from urllib.parse import quote src, dst = sys.argv[1], sys.argv[2] TAIL = b'/.dart_tool/flutter_build/dart_plugin_registrant.dart' proj = os.environ["PROJ"] # Flutter 以百分号编码嵌入非 ASCII 路径段;精确构造「当前工程」那条完整字节串 needle = quote(proj, safe="/").encode() + TAIL # 等长中性替换:保留结构性尾巴,头部用 /aaa.../ 填满到同样长度(不含用户名/工程名) repl = b'/' + b'a' * (len(needle) - 1 - len(TAIL)) + TAIL assert len(repl) == len(needle) def patch(d): c = d.count(needle) return (d.replace(needle, repl), c) if c else (d, 0) zin = zipfile.ZipFile(src); zout = zipfile.ZipFile(dst, 'w'); total = 0 for it in zin.infolist(): data = zin.read(it.filename) if it.filename.endswith('/libapp.so'): data, n = patch(data); total += n zi = zipfile.ZipInfo(it.filename, date_time=it.date_time) zi.compress_type = it.compress_type; zi.external_attr = it.external_attr zi.internal_attr = it.internal_attr; zi.create_system = it.create_system zout.writestr(zi, data) zout.close(); zin.close() print(" 精确替换次数:", total) if total == 0: print(" ⚠️ 未找到工程路径字节串,未改动(宁可保留也不乱替换)") PY "$BT/zipalign" -f -p 4 "$TMP/patched.apk" "$TMP/aligned.apk" >/dev/null 2>&1 \ && "$BT/apksigner" sign --ks "$KS" --ks-pass "pass:$SP" --ks-key-alias "$KA" --key-pass "pass:${KP:-$SP}" \ --v1-signing-enabled false --v2-signing-enabled true --out "$TMP/signed.apk" "$TMP/aligned.apk" >/dev/null 2>&1 \ && "$BT/apksigner" verify "$TMP/signed.apk" >/dev/null 2>&1 if [ $? -eq 0 ]; then cp "$TMP/signed.apk" "$APK"; echo "✅ 已清理并重签:$APK" else echo "⚠️ 清理失败,保留原包未改动:$APK" fi rm -rf "$TMP" } # Android 构建 build_android() { echo "▶️ 开始构建 Android $MODE 包..." if [ "$MODE" = "release" ]; then flutter build apk --release --target-platform android-arm64 $OBFUSCATE_ARGS APK_SOURCE="build/app/outputs/flutter-apk/app-release.apk" elif [ "$MODE" = "profile" ]; then flutter build apk --profile --target-platform android-arm64 $OBFUSCATE_ARGS APK_SOURCE="build/app/outputs/flutter-apk/app-profile.apk" else flutter build apk --debug --target-platform android-arm64 APK_SOURCE="build/app/outputs/flutter-apk/app-debug.apk" fi if [ ! -f "$APK_SOURCE" ]; then echo "❌ 构建失败,未找到 APK:$APK_SOURCE" exit 1 fi APK_NAME="${APP_NAME}_${BUILD_TAG}_${SAFE_VERSION}_${TIME_TAG}.apk" APK_DEST="${DIST_DIR}/${APK_NAME}" cp "$APK_SOURCE" "$APK_DEST" # 注意:本 App 有运行时自校验,任何出包后修改 libapp.so(即使等长精确替换并重签)都会 # 导致启动卡在启动页。故不再对产物做二进制补丁。去除内嵌构建路径改用“中性目录构建”方案。 # strip_build_path 函数保留备查,但默认不调用。 echo "✅ Android 构建成功!" echo "📁 输出路径: $APK_DEST" echo "📏 文件大小: $APK_SIZE" LAST_BUILT_FILE="$APK_DEST" } # iOS 构建(基于 flutter build ipa,一步出 ipa) build_ios() { echo "▶️ 开始构建 iOS $MODE 包..." if [ "$MODE" = "debug" ]; then echo "⚠️ iOS 不支持 debug 模式打包,请使用 release 或 profile(debug 建议直接 flutter run)" exit 1 fi IPA_OUTPUT_DIR="build/ios/ipa" ARCHIVE_OUTPUT_DIR="build/ios/archive" if [ "$MODE" = "release" ]; then if [ "$SIGN_MODE" = "nocodesign" ]; then flutter build ipa --release --no-codesign $OBFUSCATE_ARGS elif [ -n "$EXPORT_OPTIONS_PLIST" ]; then flutter build ipa --release --export-options-plist="$EXPORT_OPTIONS_PLIST" $OBFUSCATE_ARGS else flutter build ipa --release --export-method "$EXPORT_METHOD" $OBFUSCATE_ARGS fi else if [ "$SIGN_MODE" = "nocodesign" ]; then flutter build ipa --profile --no-codesign $OBFUSCATE_ARGS elif [ -n "$EXPORT_OPTIONS_PLIST" ]; then flutter build ipa --profile --export-options-plist="$EXPORT_OPTIONS_PLIST" $OBFUSCATE_ARGS else flutter build ipa --profile --export-method "$EXPORT_METHOD" $OBFUSCATE_ARGS fi fi # 查找产物 ipa IPA_SOURCE="" for candidate in "$IPA_OUTPUT_DIR"/*.ipa; do if [ -f "$candidate" ]; then IPA_SOURCE="$candidate" break fi done if [ -z "$IPA_SOURCE" ]; then if [ "$SIGN_MODE" = "nocodesign" ]; then if [ -d "$ARCHIVE_OUTPUT_DIR" ]; then echo "⚠️ 未签名模式下 Flutter 通常不会导出可安装的 IPA" echo "✅ 未签名构建产物目录:$ARCHIVE_OUTPUT_DIR" echo "📌 如需可安装/可上传的 IPA,请在 Xcode 配好 Team 和证书后用 codesign 模式重新打包" LAST_BUILT_FILE="" return fi echo "❌ 未签名构建完成,但未找到 archive 目录:$ARCHIVE_OUTPUT_DIR" exit 1 fi echo "❌ 构建结束,但未找到 IPA 文件:$IPA_OUTPUT_DIR/*.ipa" echo "📌 通常是签名证书、Provisioning Profile 或 ExportOptions 配置有问题" echo "📌 可以先试不签名出包:$0 i $MODE $EXPORT_METHOD \"$EXPORT_OPTIONS_PLIST\" nocodesign" exit 1 fi # 与 apk 命名对齐:去掉冗余的 ios 标识(后缀已区分平台),导出方式去连字符(ad-hoc→adhoc) IPA_NAME="${APP_NAME}_${BUILD_TAG}_${EXPORT_METHOD//-/}_${SAFE_VERSION}_${TIME_TAG}.ipa" IPA_DEST="${DIST_DIR}/${IPA_NAME}" cp "$IPA_SOURCE" "$IPA_DEST" IPA_SIZE=$(du -h "$IPA_DEST" | awk '{print $1}') echo "✅ iOS 构建成功!" echo "📁 输出路径: $IPA_DEST" echo "📏 文件大小: $IPA_SIZE" LAST_BUILT_FILE="$IPA_DEST" } # 分发 case "$TARGET" in a) build_android ;; i) build_ios ;; all) build_android; build_ios ;; esac # 自动在 Finder 中打开并选中产物(仅 macOS) if [[ "$OSTYPE" == "darwin"* ]]; then if [ -n "$LAST_BUILT_FILE" ] && [ -f "$LAST_BUILT_FILE" ]; then open -R "$LAST_BUILT_FILE" else open "$DIST_DIR" fi fi echo "🎉 完成"