Flutter iOS 混合工程自動化

高家二少爺發表於2019-10-28

問題

Flutter提供的混編方案直接依賴於Flutter工程和Flutter環境,非Flutte團隊成員無法脫離Flutter環境進行開發,團隊合作成本加重。

期望

Flutter預設的混編方式:不光依賴於flutter工程中的flutter產物,還依賴於flutter SDK中的xcode_backend.sh指令碼。我們希望能夠做到當專案混編的時候,沒有開發flutter的團隊成員能夠完全脫離flutter,不需要flutter專案程式碼和安裝flutter環境;而寫flutter的團隊成員能夠按照原有的混編方式以方便開發和除錯。

帶著這個目標,我們來一步一步分析混編過程。

理清依賴

iOS專案都依賴了Flutter的哪些東西

Flutter生成的iOS專案

看圖,看圖,這個是Flutter編譯生成的Runner工作空間。iOS依賴的Flutter產物都在這個Flutter資料夾中。 依次來介紹一下這些傢伙:

  • .symlinks Flutter的三方包package,是各個資料夾的索引,指向了本地的pub快取區的包。每一個包裡面都包含一個iOS的本地pod倉庫,在包的iOS資料夾中。因而Flutter包的依賴方式直接pod匯入即可。

  • App.framework 由Flutter專案的Dart程式碼編譯而成,僅僅是framework。整合的時候可以自己做成本地pod庫也可以直接拷貝進app包,然後簽名。

  • AppFrameworkInfi.plist Flutter的一些無關緊要的配置資訊,忽略

  • engine Flutter渲染引擎,也是一個本地pod倉庫

  • flutter_assets Flutter的資原始檔,圖片等,整合時拷貝進app包即可

  • FlutterPluginRegistrant Fluttter三方包的註冊程式碼,有引入三方包時,需要引入這個,也是一個本地pod倉庫

  • Generated.xcconfig Flutter相關的一些路徑資訊,配置資訊等。整個檔案會被引入到iOS工程的各個*.xcconfig配置檔案中。這些配置資訊,在xcode runscript中引入的flutter編譯嵌入指令碼xcode_backend.sh中會使用到。當然你也可以修改指令碼,去除對這個檔案的依賴。

  • podhelper.rb ruby指令碼,包含了一個 cocoapod鉤子,在pod的安裝過程中引入flutter的所有本地庫依賴,並在每個*.xcconfig配置檔案中寫進 『匯入Generated.xcconfig』的程式碼,如#include '.../Generated.xcconfig');

指令碼分析

本質上,理清依賴的前提是 閱讀指令碼,提前貼出來是為了分析指令碼的時候能夠更好地理解過程。

預設的混編方案流程是 1 在Podfile加入指令碼

#Flutter工程路徑
flutter_application_path = 'flutter_project_dir'
#讀取 podhelper.rb 的Ruby程式碼在當前目錄執行
eval(File.read(File.join(flutter_application_path, '.ios', 'Flutter', 'podhelper.rb')), binding)
複製程式碼

2 新增Run script 指令碼

"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" build 
"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" embed
複製程式碼

然後pod install即可。

一切的祕㊙️就在這兩個指令碼中。

分析podhelper.rb

這個Ruby指令碼只有七十多行,鑑於不是每個人都熟悉Ruby指令碼,我詳細註釋了一下:

# 解析檔案內容為字典陣列
# 檔案內容格式為  A=B換行C=D   的型別
# 如 A=B
#    C=D
# 解析為:
# {"A"="B","C"="D"}

def parse_KV_file(file, separator='=')
    file_abs_path = File.expand_path(file)
    if !File.exists? file_abs_path
        return [];
    end
    pods_array = []
    skip_line_start_symbols = ["#", "/"]
    File.foreach(file_abs_path) { |line|
        next if skip_line_start_symbols.any? { |symbol| line =~ /^\s*#{symbol}/ }
        plugin = line.split(pattern=separator)
        if plugin.length == 2
            podname = plugin[0].strip()
            path = plugin[1].strip()
            podpath = File.expand_path("#{path}", file_abs_path)
            pods_array.push({:name => podname, :path => podpath});
         else
            puts "Invalid plugin specification: #{line}"
        end
    }
    return pods_array
end


# 這是個函式,功能是從flutter工程生成的iOS依賴目錄中的Generated.xcconfig檔案解析
# FLUTTER_ROOT目錄,也就是你安裝的flutter SDKf根目錄
def flutter_root(f)
    generated_xcode_build_settings = parse_KV_file(File.join(f, File.join('.ios', 'Flutter', 'Generated.xcconfig')))
    if generated_xcode_build_settings.empty?
        puts "Generated.xcconfig must exist. Make sure `flutter packages get` is executed in ${f}."
        exit
    end
    generated_xcode_build_settings.map { |p|
        if p[:name] == 'FLUTTER_ROOT'
            return p[:path]
        end
    }
end


# 程式碼入口在這裡
# flutter工程目錄,如果沒有值,則取向上退兩級的目錄(也就是Flutter生成整個iOS專案的情況)
flutter_application_path ||= File.join(__dir__, '..', '..')
# Flutter生成的framework目錄,引擎庫,編譯完成的程式碼庫等幾乎所有iOS專案的依賴都放在這裡
framework_dir = File.join(flutter_application_path, '.ios', 'Flutter')

# flutter引擎目錄
engine_dir = File.join(framework_dir, 'engine')

# 如果引擎目錄不存在就去 flutter SDK目錄中拷貝一份,引擎是一個本地pod庫
# File.join,功能是拼接檔案目錄
if !File.exist?(engine_dir)
    # 這個是debug版本的flutter引擎目錄,release的最後一級為「ios-release」,profile版本為ios-profile
    debug_framework_dir = File.join(flutter_root(flutter_application_path), 'bin', 'cache', 'artifacts', 'engine', 'ios')
    FileUtils.mkdir_p(engine_dir)
    FileUtils.cp_r(File.join(debug_framework_dir, 'Flutter.framework'), engine_dir)
    FileUtils.cp(File.join(debug_framework_dir, 'Flutter.podspec'), engine_dir)
end

# 這個應該每個人都很熟悉
#載入flutter引擎pod庫
pod 'Flutter', :path => engine_dir
#載入flutter三方庫的註冊程式碼庫
pod 'FlutterPluginRegistrant', :path => File.join(framework_dir, 'FlutterPluginRegistrant')

#flutter三方庫的快捷方式資料夾,最終索引到pub快取中的各個庫的目錄
symlinks_dir = File.join(framework_dir, '.symlinks')
FileUtils.mkdir_p(symlinks_dir)
#解析.flutter-plugins檔案,獲取當前flutter工程用到的三方庫
plugin_pods = parse_KV_file(File.join(flutter_application_path, '.flutter-plugins'))
#載入當前工程用到的每一個pod庫
plugin_pods.map { |r|
    symlink = File.join(symlinks_dir, r[:name])
    FileUtils.rm_f(symlink)
    File.symlink(r[:path], symlink)
    pod r[:name], :path => File.join(symlink, 'ios')
}

# 修改所有pod庫的 ENABLE_BITCODE 為 NO,含原生程式碼引用的pod庫
# 並在每一個pod庫的.xcconfig檔案中引入Generated.xcconfig檔案
# 該檔案中包含一系列flutter需要用到的變數,具體在xcode_backend.sh指令碼中會使用到
post_install do |installer|
    installer.pods_project.targets.each do |target|
        target.build_configurations.each do |config|
            config.build_settings['ENABLE_BITCODE'] = 'NO'
            xcconfig_path = config.base_configuration_reference.real_path
            File.open(xcconfig_path, 'a+') do |file|
                file.puts "#include \"#{File.realpath(File.join(framework_dir, 'Generated.xcconfig'))}\""
            end
        end
    end
end

複製程式碼

總結一下,這個Ruby指令碼舊做了以下這幾件事情

  • 引入Flutter引擎
  • 引入Flutter三方庫的註冊程式碼
  • 引入Flutter的所有三方庫
  • 在每一個pod庫的配置檔案中寫入對Generated.xcconfig 檔案的匯入
  • 修改pod庫的的ENABLE_BITCODE

至此,還缺少Dart程式碼庫以及flutter引入的資源,這個在xcode_backend.sh指令碼實現了。這個指令碼在flutter SDK的packages/flutter_tools/bin

同樣看一下所有程式碼,以及詳細註釋:

#!/bin/bash
# Copyright 2016 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.

RunCommand() {
  if [[ -n "$VERBOSE_SCRIPT_LOGGING" ]]; then
    echo "♦ $*"
  fi
  "$@"
  return $?
}

# When provided with a pipe by the host Flutter build process, output to the
# pipe goes to stdout of the Flutter build process directly.
StreamOutput() {
  if [[ -n "$SCRIPT_OUTPUT_STREAM_FILE" ]]; then
    echo "$1" > $SCRIPT_OUTPUT_STREAM_FILE
  fi
}

EchoError() {
  echo "$@" 1>&2
}

# 驗證路徑中的資源是否存在
AssertExists() {
  if [[ ! -e "$1" ]]; then
    if [[ -h "$1" ]]; then
      EchoError "The path $1 is a symlink to a path that does not exist"
    else
      EchoError "The path $1 does not exist"
    fi
    exit -1
  fi
  return 0
}

BuildApp() {
  #xcode工程根目錄,SOURCE_ROOT這個變數來自xcode工程環境
  local project_path="${SOURCE_ROOT}/.."

#FLUTTER_APPLICATION_PATH flutter工程目錄,該變數來自Generated.xcconfig檔案
#若FLUTTER_APPLICATION_PATH不為空則,賦值給project_path
  if [[ -n "$FLUTTER_APPLICATION_PATH" ]]; then
    project_path="${FLUTTER_APPLICATION_PATH}"
  fi

#flutter的程式入口檔案目錄
  local target_path="lib/main.dart"
  if [[ -n "$FLUTTER_TARGET" ]]; then
    target_path="${FLUTTER_TARGET}"
  fi

  # Use FLUTTER_BUILD_MODE if it's set, otherwise use the Xcode build configuration name
  # This means that if someone wants to use an Xcode build config other than Debug/Profile/Release,
  # they _must_ set FLUTTER_BUILD_MODE so we know what type of artifact to build.
# 獲取編譯模式
# 根據編譯模式設定相應變數
# artifact_variant是後續拷貝flutter引擎的時候使用,決定引擎的版本
# 在podhelper.rb中已經把flutter引擎整合進去了,不過依賴的是flutter工程本身編譯模式引入的版本,可能不同
# 所以在這個指令碼之中希望能夠重新引入相應模式的engine

  local build_mode="$(echo "${FLUTTER_BUILD_MODE:-${CONFIGURATION}}" | tr "[:upper:]" "[:lower:]")"
  local artifact_variant="unknown".
  case "$build_mode" in
    release*) build_mode="release"; artifact_variant="ios-release";;
    profile*) build_mode="profile"; artifact_variant="ios-profile";;
    debug*) build_mode="debug"; artifact_variant="ios";;
    *)
      EchoError "========================================================================"
      EchoError "ERROR: Unknown FLUTTER_BUILD_MODE: ${build_mode}."
      EchoError "Valid values are 'Debug', 'Profile', or 'Release' (case insensitive)."
      EchoError "This is controlled by the FLUTTER_BUILD_MODE environment varaible."
      EchoError "If that is not set, the CONFIGURATION environment variable is used."
      EchoError ""
      EchoError "You can fix this by either adding an appropriately named build"
      EchoError "configuration, or adding an appriate value for FLUTTER_BUILD_MODE to the"
      EchoError ".xcconfig file for the current build configuration (${CONFIGURATION})."
      EchoError "========================================================================"
      exit -1;;
  esac

  # Archive builds (ACTION=install) should always run in release mode.
  if [[ "$ACTION" == "install" && "$build_mode" != "release" ]]; then
    EchoError "========================================================================"
    EchoError "ERROR: Flutter archive builds must be run in Release mode."
    EchoError ""
    EchoError "To correct, ensure FLUTTER_BUILD_MODE is set to release or run:"
    EchoError "flutter build ios --release"
    EchoError ""
    EchoError "then re-run Archive from Xcode."
    EchoError "========================================================================"
    exit -1
  fi

  #flutter引擎的詳細地址
  local framework_path="${FLUTTER_ROOT}/bin/cache/artifacts/engine/${artifact_variant}"

  AssertExists "${framework_path}"
  AssertExists "${project_path}"

#flutter的目標存放目錄
  local derived_dir="${SOURCE_ROOT}/Flutter"
  if [[ -e "${project_path}/.ios" ]]; then
    derived_dir="${project_path}/.ios/Flutter"
  fi
  RunCommand mkdir -p -- "$derived_dir"
  AssertExists "$derived_dir"


  RunCommand rm -rf -- "${derived_dir}/App.framework"

  local local_engine_flag=""
  local flutter_framework="${framework_path}/Flutter.framework"
  local flutter_podspec="${framework_path}/Flutter.podspec"

# 如果本地的引擎存在,則引擎使用此路徑,後續拷貝引擎從這個目錄拷貝
  if [[ -n "$LOCAL_ENGINE" ]]; then
    if [[ $(echo "$LOCAL_ENGINE" | tr "[:upper:]" "[:lower:]") != *"$build_mode"* ]]; then
      EchoError "========================================================================"
      EchoError "ERROR: Requested build with Flutter local engine at '${LOCAL_ENGINE}'"
      EchoError "This engine is not compatible with FLUTTER_BUILD_MODE: '${build_mode}'."
      EchoError "You can fix this by updating the LOCAL_ENGINE environment variable, or"
      EchoError "by running:"
      EchoError "  flutter build ios --local-engine=ios_${build_mode}"
      EchoError "or"
      EchoError "  flutter build ios --local-engine=ios_${build_mode}_unopt"
      EchoError "========================================================================"
      exit -1
    fi
    local_engine_flag="--local-engine=${LOCAL_ENGINE}"
    flutter_framework="${LOCAL_ENGINE}/Flutter.framework"
    flutter_podspec="${LOCAL_ENGINE}/Flutter.podspec"
  fi

#複製Flutter engine 到依賴目錄
  if [[ -e "${project_path}/.ios" ]]; then
    RunCommand rm -rf -- "${derived_dir}/engine"
    mkdir "${derived_dir}/engine"
    RunCommand cp -r -- "${flutter_podspec}" "${derived_dir}/engine"
    RunCommand cp -r -- "${flutter_framework}" "${derived_dir}/engine"
    RunCommand find "${derived_dir}/engine/Flutter.framework" -type f -exec chmod a-w "{}" \;
  else
    RunCommand rm -rf -- "${derived_dir}/Flutter.framework"
    RunCommand cp -r -- "${flutter_framework}" "${derived_dir}"
    RunCommand find "${derived_dir}/Flutter.framework" -type f -exec chmod a-w "{}" \;
  fi

# 切換指令碼執行目錄到flutter工程,以便執行flutter命令
  RunCommand pushd "${project_path}" > /dev/null

  AssertExists "${target_path}"

# 是否需要詳細日誌的輸出標記
  local verbose_flag=""
  if [[ -n "$VERBOSE_SCRIPT_LOGGING" ]]; then
    verbose_flag="--verbose"
  fi
#flutter build 目錄
  local build_dir="${FLUTTER_BUILD_DIR:-build}"

#是否檢測weidget的建立,release模式不支援此引數
  local track_widget_creation_flag=""
  if [[ -n "$TRACK_WIDGET_CREATION" ]]; then
    track_widget_creation_flag="--track-widget-creation"
  fi

# 非debug模式:執行flutter build aot ios ……編譯dart程式碼成app.framework
#            生成 dSYM 檔案
#            剝離除錯符號表

# debug模式:把『static const int Moo = 88;』這句程式碼打成app.framework,
#           直接使用JIT模式的快照
  if [[ "${build_mode}" != "debug" ]]; then
    StreamOutput " ├─Building Dart code..."
    # Transform ARCHS to comma-separated list of target architectures.
    local archs="${ARCHS// /,}"
    if [[ $archs =~ .*i386.* || $archs =~ .*x86_64.* ]]; then
      EchoError "========================================================================"
      EchoError "ERROR: Flutter does not support running in profile or release mode on"
      EchoError "the Simulator (this build was: '$build_mode')."
      EchoError "You can ensure Flutter runs in Debug mode with your host app in release"
      EchoError "mode by setting FLUTTER_BUILD_MODE=debug in the .xcconfig associated"
      EchoError "with the ${CONFIGURATION} build configuration."
      EchoError "========================================================================"
      exit -1
    fi
    #執行flutter的編譯命令

    RunCommand "${FLUTTER_ROOT}/bin/flutter" --suppress-analytics           \
      ${verbose_flag}                                                       \
      build aot                                                             \
      --output-dir="${build_dir}/aot"                                       \
      --target-platform=ios                                                 \
      --target="${target_path}"                                             \
      --${build_mode}                                                       \
      --ios-arch="${archs}"                                                 \
      ${local_engine_flag}                                                  \
      ${track_widget_creation_flag}

    if [[ $? -ne 0 ]]; then
      EchoError "Failed to build ${project_path}."
      exit -1
    fi
    StreamOutput "done"

    local app_framework="${build_dir}/aot/App.framework"

    RunCommand cp -r -- "${app_framework}" "${derived_dir}"

    StreamOutput " ├─Generating dSYM file..."
    # Xcode calls `symbols` during app store upload, which uses Spotlight to
    # find dSYM files for embedded frameworks. When it finds the dSYM file for
    # `App.framework` it throws an error, which aborts the app store upload.
    # To avoid this, we place the dSYM files in a folder ending with ".noindex",
    # which hides it from Spotlight, https://github.com/flutter/flutter/issues/22560.
    RunCommand mkdir -p -- "${build_dir}/dSYMs.noindex"
# 生成 dSYM 檔案
    RunCommand xcrun dsymutil -o "${build_dir}/dSYMs.noindex/App.framework.dSYM" "${app_framework}/App"
    if [[ $? -ne 0 ]]; then
      EchoError "Failed to generate debug symbols (dSYM) file for ${app_framework}/App."
      exit -1
    fi
    StreamOutput "done"

    StreamOutput " ├─Stripping debug symbols..."
# 剝離除錯符號表
    RunCommand xcrun strip -x -S "${derived_dir}/App.framework/App"
    if [[ $? -ne 0 ]]; then
      EchoError "Failed to strip ${derived_dir}/App.framework/App."
      exit -1
    fi
    StreamOutput "done"

  else
    RunCommand mkdir -p -- "${derived_dir}/App.framework"

    # Build stub for all requested architectures.
    local arch_flags=""
    # 獲取當前除錯模式的架構引數
    #模擬器是x86_64
    #真機則根據實際的架構armv7或arm64
    read -r -a archs <<< "$ARCHS"
    for arch in "${archs[@]}"; do
      arch_flags="${arch_flags}-arch $arch "
    done

    RunCommand eval "$(echo "static const int Moo = 88;" | xcrun clang -x c \
        ${arch_flags} \
        -dynamiclib \
        -Xlinker -rpath -Xlinker '@executable_path/Frameworks' \
        -Xlinker -rpath -Xlinker '@loader_path/Frameworks' \
        -install_name '@rpath/App.framework/App' \
        -o "${derived_dir}/App.framework/App" -)"
  fi

    #嵌入Info.plist
  local plistPath="${project_path}/ios/Flutter/AppFrameworkInfo.plist"
  if [[ -e "${project_path}/.ios" ]]; then
    plistPath="${project_path}/.ios/Flutter/AppFrameworkInfo.plist"
  fi

  RunCommand cp -- "$plistPath" "${derived_dir}/App.framework/Info.plist"

  local precompilation_flag=""
  if [[ "$CURRENT_ARCH" != "x86_64" ]] && [[ "$build_mode" != "debug" ]]; then
    precompilation_flag="--precompiled"
  fi

# 編譯 資源包,若是debug模式則會包含flutter程式碼的JIT編譯快照,此時app.framework中不含dart程式碼
  StreamOutput " ├─Assembling Flutter resources..."
  RunCommand "${FLUTTER_ROOT}/bin/flutter" --suppress-analytics             \
    ${verbose_flag}                                                         \
    build bundle                                                            \
    --target-platform=ios                                                   \
    --target="${target_path}"                                               \
    --${build_mode}                                                         \
    --depfile="${build_dir}/snapshot_blob.bin.d"                            \
    --asset-dir="${derived_dir}/flutter_assets"                             \
    ${precompilation_flag}                                                  \
    ${local_engine_flag}                                                    \
    ${track_widget_creation_flag}

  if [[ $? -ne 0 ]]; then
    EchoError "Failed to package ${project_path}."
    exit -1
  fi
  StreamOutput "done"
  StreamOutput " └─Compiling, linking and signing..."

  RunCommand popd > /dev/null

  echo "Project ${project_path} built and packaged successfully."
  return 0
}

# Returns the CFBundleExecutable for the specified framework directory.
GetFrameworkExecutablePath() {
  local framework_dir="$1"

  local plist_path="${framework_dir}/Info.plist"
  local executable="$(defaults read "${plist_path}" CFBundleExecutable)"
  echo "${framework_dir}/${executable}"
}

# Destructively thins the specified executable file to include only the
# specified architectures.
LipoExecutable() {
  local executable="$1"
  shift
  # Split $@ into an array.
  read -r -a archs <<< "$@"

  # Extract architecture-specific framework executables.
  local all_executables=()
  for arch in "${archs[@]}"; do
    local output="${executable}_${arch}"
    local lipo_info="$(lipo -info "${executable}")"
    if [[ "${lipo_info}" == "Non-fat file:"* ]]; then
      if [[ "${lipo_info}" != *"${arch}" ]]; then
        echo "Non-fat binary ${executable} is not ${arch}. Running lipo -info:"
        echo "${lipo_info}"
        exit 1
      fi
    else
      lipo -output "${output}" -extract "${arch}" "${executable}"
      if [[ $? == 0 ]]; then
        all_executables+=("${output}")
      else
        echo "Failed to extract ${arch} for ${executable}. Running lipo -info:"
        lipo -info "${executable}"
        exit 1
      fi
    fi
  done

  # Generate a merged binary from the architecture-specific executables.
  # Skip this step for non-fat executables.
  if [[ ${#all_executables[@]} > 0 ]]; then
    local merged="${executable}_merged"
    lipo -output "${merged}" -create "${all_executables[@]}"

    cp -f -- "${merged}" "${executable}" > /dev/null
    rm -f -- "${merged}" "${all_executables[@]}"
  fi
}

# Destructively thins the specified framework to include only the specified
# architectures.
ThinFramework() {
  local framework_dir="$1"
  shift

  local plist_path="${framework_dir}/Info.plist"
  local executable="$(GetFrameworkExecutablePath "${framework_dir}")"
  LipoExecutable "${executable}" "$@"
}

ThinAppFrameworks() {
  local app_path="${TARGET_BUILD_DIR}/${WRAPPER_NAME}"
  local frameworks_dir="${app_path}/Frameworks"

  [[ -d "$frameworks_dir" ]] || return 0
  find "${app_path}" -type d -name "*.framework" | while read framework_dir; do
    ThinFramework "$framework_dir" "$ARCHS"
  done
}

# Adds the App.framework as an embedded binary and the flutter_assets as
# resources.
# 主要做了這幾件事:
# 複製flutter_asserts到app包
# 複製Flutter引擎到app包
# 複製dart程式碼編譯產物app.framework到app包
# 簽名兩個framework
EmbedFlutterFrameworks() {
  AssertExists "${FLUTTER_APPLICATION_PATH}"

  # Prefer the hidden .ios folder, but fallback to a visible ios folder if .ios
  # doesn't exist.
  local flutter_ios_out_folder="${FLUTTER_APPLICATION_PATH}/.ios/Flutter"
  local flutter_ios_engine_folder="${FLUTTER_APPLICATION_PATH}/.ios/Flutter/engine"
  if [[ ! -d ${flutter_ios_out_folder} ]]; then
    flutter_ios_out_folder="${FLUTTER_APPLICATION_PATH}/ios/Flutter"
    flutter_ios_engine_folder="${FLUTTER_APPLICATION_PATH}/ios/Flutter"
  fi

  AssertExists "${flutter_ios_out_folder}"

  # Copy the flutter_assets to the Application's resources.
  AssertExists "${CONFIGURATION_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/"
  RunCommand cp -r -- "${flutter_ios_out_folder}/flutter_assets" "${CONFIGURATION_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/"

  # Embed App.framework from Flutter into the app (after creating the Frameworks directory
  # if it doesn't already exist).
  local xcode_frameworks_dir=${BUILT_PRODUCTS_DIR}"/"${PRODUCT_NAME}".app/Frameworks"
  RunCommand mkdir -p -- "${xcode_frameworks_dir}"
  RunCommand cp -Rv -- "${flutter_ios_out_folder}/App.framework" "${xcode_frameworks_dir}"

  # Embed the actual Flutter.framework that the Flutter app expects to run against,
  # which could be a local build or an arch/type specific build.
  # Remove it first since Xcode might be trying to hold some of these files - this way we're
  # sure to get a clean copy.
  RunCommand rm -rf -- "${xcode_frameworks_dir}/Flutter.framework"
  RunCommand cp -Rv -- "${flutter_ios_engine_folder}/Flutter.framework" "${xcode_frameworks_dir}/"

  # Sign the binaries we moved.
  local identity="${EXPANDED_CODE_SIGN_IDENTITY_NAME:-$CODE_SIGN_IDENTITY}"
  if [[ -n "$identity" && "$identity" != "\"\"" ]]; then
    RunCommand codesign --force --verbose --sign "${identity}" -- "${xcode_frameworks_dir}/App.framework/App"
    RunCommand codesign --force --verbose --sign "${identity}" -- "${xcode_frameworks_dir}/Flutter.framework/Flutter"
  fi
}

# 主函式入口
# 以下結合xcode run srcript中的指令碼就很好理解
# 編譯、嵌入
#"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" build
#"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" embed

if [[ $# == 0 ]]; then # 如果不帶引數則直接執行BuildApp函式
  # Backwards-compatibility: if no args are provided, build.
  BuildApp
else # 否則執行case語句
  case $1 in
    "build")
      BuildApp ;;
    "thin")
      ThinAppFrameworks ;;
    "embed")
      EmbedFlutterFrameworks ;;
  esac
fi

複製程式碼

同樣,總結一下,這個shell指令碼做了以下事情

  • 編譯Dart程式碼為App.framework(非debug模式),編譯static const int Moo = 88;為App.framework(猜測此行程式碼為JIT/AOT模式切換標記)
  • 重新匯入Flutter引擎的對應模式版本(debug/profile/release)
  • 編譯flutter資源(flutter_asserts),如果是debug 資源中會包含JIT模式的程式碼快照
  • 向iOS app包中嵌入資源,框架,簽名

這一節大部分都貼程式碼了,如果是簡單講過程可能不是很好理解,詳細的大家還是直接讀指令碼吧。如果看不懂指令碼,看註釋也是能夠了解個大概。

方案

依賴以及過程都理清楚了,最後是時候說方案了。 回頭看一起期望

  • 非flutter開發人員可完全脫離Flutter環境
  • flutter開發人員仍按照原有的依賴方式

到了這裡,我們還是希望能夠做的更好一點,就是能夠實現兩種模式的切換。大概畫了一個圖,大家將就看一下。

混編方案

方案大概的解決方法就是:

  • 完全脫離Flutter環境:(圖中實線流程部分) 利用指令碼將所有的依賴編譯結果從Flutter工程中剝離出來,放到iOS工程目錄下。iOS native直接依賴此目錄,不再編譯,即可以脫離Flutter環境了。(環境可以直接是release,因為脫離Flutter的環境不會去除錯Flutter程式碼的。)

  • 直接依賴Flutter工程:(圖中虛線流程部分) 直接依賴時,pod對Flutter的依賴都直接指向了Flutter工程;另外就是xcode_backend.sh會去重新編譯Flutter程式碼,Flutter資源並嵌入app;Flutter引擎也會重新嵌入相應模式的版本。

方案存在的問題

直接依賴Flutter工程的方式,這個大同小異,都是直接或間接指向Flutter工程。這裡重點討論完全脫離Flutter環境的方案。

以鹹魚為代表的遠端Flutter方案

Flutter遠端依賴

鹹魚團隊自己也提到存在以下問題

  1. Flutter工程更新,遠端依賴庫更新不及時。
  2. 版本整合時,容易忘記更新遠端依賴庫,導致版本沒有整合最新Flutter功能。
  3. 同時多條線並行開發Flutter時,版本管理混亂,容易出現遠端庫被覆蓋的問題。
  4. 需要最少一名同學持續跟進發布,人工成本較高。 鑑於這些問題,我們引入了我們團隊的CI自動化框架,從兩方面來解決: (關於CI自動化框架,我們後續會撰文分享) 一方面是自動化,通過自動化減少人工成本,也減少人為失誤。
    另一方面是做好版本控制, 自動化的形式來做版本控制。
    具體操作:
    首先,每次需要構建純粹Native工程前自動完成Flutter工程對應的遠端庫的編譯釋出工作,整個過程不需要人工干預。 其次,在開發測試階段,採用五段式的版本號,最後一位自動遞增產生,這樣就可以保證測試階段的所有並行開發的Flutter庫的版本號不會產生衝突。 最後,在釋出階段,採用三段式或四段式的版本號,可以和APP版本號保持一致,便於後續問題追溯。
我們的方案

直接把Flutter放在原生工程中

這個方案相比鹹魚的方案解決了原生依賴Flutter庫版本號的問題。放在原生之中的Flutter依賴直接歸為原生管理,不需要獨立的版本。這個依賴拿到的是Flutter開發成員釋出的程式碼,一般情況下都是對應分支的最新flutter程式碼編譯產物。 如iOS的dev對應Flutter的dev,齊頭並進,版本管理上就會簡單的多。

但是同樣會有Flutter依賴更新不及時等這些其他問題,有待進一步調研和實踐。


延伸閱讀:Flutter試用報告


參考文章: 閒魚Fultter混合工程持續整合的最佳實踐

相關文章