從零搭建 iOS Native Flutter 混合工程
一、Flutter 提供的 Native Flutter 混合工程方式
Flutter 官方提供的混合工程搭建方法:Add Flutter to existing apps 文章中介紹瞭如何在現有 App 里加入Flutter,下面進行逐步介紹一下
1. 建立 Flutter 工程
請自行 百度/Google Flutter 安裝教程,安裝Flutter。然後到任意目錄下執行flutter create -t module my_flutter
,"my_flutter" 是要建立的 Flutter 工程的名稱。
2. 通過 Cocoapods 將 Flutter 引入 現有 Native 工程
在Podfile新增以下下程式碼
flutter_application_path = "xxx/xxx/my_flutter" eval(File.read(File.join(flutter_application_path, '.ios', 'Flutter', 'podhelper.rb')), binding)
然後執行pod install
3. 修改 Native 工程
開啟Xcode工程,選擇要加入 Flutter App 的 target,選擇 Build Phases,點選頂部的 + 號,選擇 New Run Script Phase,然後輸入以下指令碼
"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" build "$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" embed
二、分析 Native Flutter 混合工程
按照上面三個步驟進行逐一分析每一步的問題,並提供優化方案。
1. 建立 Flutter 工程
這一步首先在自己電腦上安裝 Flutter,然後使用flutter create
。這裡就存在問題,在團隊開發中每個人安裝的 Flutter 版本可能並不同,這樣會出現Dart層Api相容性或Flutter虛擬機器不一致等問題。 在團隊協作中一定要保證 Flutter 工程依賴相同的 Flutter SDK,所有需要一個工具在執行flutter
指令時可以根據當前 Flutter 工程使用對應版本的 Flutter SDK,目前有一個名叫flutter_wrapper
的工具,使用flutterw
代替flutter
指令,工具會自動將 Flutter SDK 放在當前 Flutter 工程目錄下,並執行當前工程中的flutter
命令,這樣就不再依賴本地電腦安裝的 Flutter SDK。
flutter_wrapper使用:
1、flutter create 建立 Flutter 工程,這裡使用的是本地的 Flutter SDK
2、進入 Flutter 工程目錄安裝 'flutter_wrapper',執行 sh -c "$(curl -fsSL
https://raw.githubusercontent.com/passsy/flutter_wrapper/master/install.sh )"
3、此後在當前 Flutter 工程需要使用 flutter 命令的地方都使用 ./flutterw來代替
2. 通過 Cocoapods 將 Flutter 引入 現有 Native 工程
這一步在 Podfile 裡添加里一個 'podhelper.rb' ruby 指令碼,指令碼會在 pod install/update 時執行,指令碼主要做4點事情:
1、解析 'Generated.xcconfig' 檔案,獲取 Flutter 工程配置資訊,檔案在'my_flutter/.ios/Flutter/'目錄下,檔案中包含了 Flutter SDK 路徑、Flutter 工程路徑、Flutter 工程入口、編譯目錄等。
2、將 Flutter SDK 中的 Flutter.framework 通過 pod 新增到 Native 工程。
3、將 Flutter 工程依賴的外掛通過 pod 新增到 Native 工程,因為有些外掛有 Native 部分程式碼。
4、使用 post_install 這個 pod hooks 來關閉 Native 工程的 bitcode,並將 'Generated.xcconfig' 檔案加入 Native 工程。
這一步存在問題是,'podhelper.rb'指令碼是通過一個本地 Flutter 工程路徑'flutter_application_path'來讀取,在團隊協作中我們很難保證每個人的本地 Flutter 工程路徑都一樣,在同步程式碼時大家可能都要頻繁修改'flutter_application_path'變數,這樣很不友好。
解決這個問題的思路就是將 Flutter 工程放在當前 Native 工程的目錄下,我們可以再加入一個 ruby 指令碼,在每次執行 pod install/update 時,將 Flutter 工程從 git 上拉取一份放在當前目錄下,這樣 Flutter 工程的路徑就統一了。大致程式碼如下:
flutter_application_path = __dir__ + "/.flutter/app" `git clone git://xxxx/my_flutter.git #{flutter_application_path}` # 如果想要除錯本地的 Flutter 工程,就把下面這行註釋放開 # flutter_application_path = "xxx/xxx/my_flutter" eval(File.read(File.join(flutter_application_path, '.ios', 'Flutter', 'podhelper.rb')), binding)
上述程式碼只是臨時程式碼,為了演示將 Flutter 工程放在當前目錄下這個思路,後面會有完整的實現程式碼。
3. 修改 Native 工程
這裡執行了一個'xcode_backend.sh'指令碼的兩個命令build、embed,兩個命令分別的作業是:
build: 根據當前 Xcode 工程的 'configuration' 和其他編譯配置編譯 Flutter 工程,'configuration'通常為'debug'或者'release'
embed: 將 build 出來的 framework、資源包放入 Xcode 編譯目錄,並簽名 framework
這裡存在的問題是:Flutter 工程依賴 Native工程來執行編譯,並影響Native工程的開發流程與打包流程。
通常 'configuration' 裡不止有 'debug' 或者 'release',可能會有自定義的名稱,如果我們使用自定義的 'configuration' 編譯,那麼 xcode_backend.sh build 就會執行失敗。因為Flutter 編譯模式是通過 'configuration' 獲取的,Flutter 支援 Debug、Profil、Release 三種編譯模式,而我們自定義的名稱不在這三種之中,Flutter 就不知道該怎麼編譯。
每次 Native 編譯時 Flutter 就需要編譯,其實是產生了相互依賴:Flutter 編譯依賴 Native 編譯環境,Native 依賴 Flutter 編譯通過。
我們希望做到:Native 依賴 Flutter 編譯出來的產物,並且保留依賴 Flutter 原始碼進行除錯的能力。
實現這個目標我們需要兩部分:
第一部分:Flutter 工程裡建立一個打包指令碼,可以一鍵產生 Flutter 工程產物;
第二部分:在 Native 工程獲取 FLutter 工程的編譯產物,並通過 pod 新增到工程;並且保留依賴 Flutter 工程原始碼的功能。
三、實現 Native Flutter 混合工程
下面我們來實現上文提到的兩個部分
第一部分實現“打包指令碼”
這一部分我們需要實現指令碼自動打包 Flutter 工程,拆分一下這個指令碼流程,大致分為一下幾個步驟:
1、檢查 Flutter 環境,拉取 Flutter plugin
2、編譯 Flutter 工程產物
3、複製 Flutter 外掛中的 Native 程式碼
4、將產物同步到產物釋出的伺服器
下面來一步一步的分析並實現每一步:
(1) 檢查 Flutter 環境,拉取 Flutter plugin
這一步做的工作是檢查是否安裝了 'flutter_wrapper',如果安裝則進行安裝,然後執行 ./flutterw packages get,Shell程式碼如下:
flutter_get_packages() { echo "=================================" echo "Start get flutter app plugin" local flutter_wrapper="./flutterw" if [ -e $flutter_wrapper ]; then echo 'flutterw installed' >/dev/null else bash -c "$(curl -fsSL https://raw.githubusercontent.com/passsy/flutter_wrapper/master/install.sh)" if [[ $? -ne 0 ]]; then # 上一步指令碼執行失敗 echo "Failed to installed flutter_wrapper." exit -1 fi fi ${flutter_wrapper} packages get --verbose if [[ $? -ne 0 ]]; then # 上一步指令碼執行失敗 echo "Failed to install flutter plugins." exit -1 fi echo "Finish get flutter app plugin"
(2) 編譯 Flutter 工程產物
這一步是指令碼的核心,主要邏輯和上文中'xcode_backend.sh build'類似,大致程式碼如下:
# 預設debug編譯模式 BUILD_MODE="debug" # 編譯的cpu平臺 ARCHS_ARM="arm64,armv7" # Flutter SDK 路徑 FLUTTER_ROOT=".flutter" # 編譯目錄 BUILD_PATH=".build_ios/${BUILD_MODE}" # 存放產物的目錄 PRODUCT_PATH="${BUILD_PATH}/product" # 編譯出的flutter framework 存放的目錄 PRODUCT_APP_PATH="${PRODUCT_PATH}/Flutter" build_flutter_app() { echo "=================================" echo "Start Build flutter app" # 建立目錄 mkdir -p -- "${PRODUCT_APP_PATH}" # flutter 工程入口 dart檔案 local target_path="lib/main.dart" # flutter sdk 目錄解析 local artifact_variant="unknown" case "$BUILD_MODE" in release*) artifact_variant="ios-release" ;; profile*) artifact_variant="ios-profile" ;; debug*) artifact_variant="ios" ;; *) echo "ERROR: Unknown FLUTTER_BUILD_MODE: ${BUILD_MODE}." exit -1 ;; esac if [[ "${BUILD_MODE}" != "debug" ]]; then # 非debug編譯模式 # build fLutter app,output App.framework ${FLUTTER_ROOT}/bin/flutter --suppress-analytics \ --verbose \ build aot \ --output-dir="${BUILD_PATH}" \ --target-platform=ios \ --target="${target_path}" \ --${BUILD_MODE} \ --ios-arch="${ARCHS_ARM}" if [[ $? -ne 0 ]]; then echo "Failed to build flutter app" exit -1 fi else # debug編譯模式直接使用編譯好的App.framework, # 因為在 debug 模式下 flutter 程式碼並沒有編譯成二進位制機器碼,而是在後續build bundle時被打包進資源包, # 在'xcode_backend.sh'腳本里,這一步這裡只是編譯成一個App.framework空殼。 # 提前編譯好的原因是'xcode_backend.sh'指令碼執行和Xcode一起執行,所以執行時能獲取到Xcode設定的編譯配置,能正確的編譯出'App.framework', # 而本指令碼不依賴Xcode執行,即便把'xcode_backend.sh'對應的程式碼拷貝出來也不能正確的編譯出'App.framework',除非我們能正確的配置編譯環境。 # # 而我又不想那麼麻煩,選擇另闢蹊徑: # 隨便建立了一個 Flutter 工程, # 在debug模式下,先在模擬器編譯執行一下,得到x86_64的App.framework, # 再到真機執行一下,得到arm64/armv7的App.framework, # 最後使用lipo命令將兩個App.framework合併,得到x86_64/arm64/armv7的App.framework, # 這樣最後得到的App.framework在模擬器和真機都可以用 # 因為debug模式下App.framework就是佔位的空殼,所以其他flutter工程一樣用 local app_framework_debug="iOSApp/Debug/App.framework" cp -r -- "${app_framework_debug}" "${BUILD_PATH}" fi # copy info.plist to App.framework app_plist_path=".ios/Flutter/AppFrameworkInfo.plist" cp -- "${app_plist_path}" "${BUILD_PATH}/App.framework/Info.plist" local framework_path="${FLUTTER_ROOT}/bin/cache/artifacts/engine/${artifact_variant}" local flutter_framework="${framework_path}/Flutter.framework" local flutter_podspec="${framework_path}/Flutter.podspec" # copy framework to PRODUCT_APP_PATH cp -r -- "${BUILD_PATH}/App.framework" "${PRODUCT_APP_PATH}" cp -r -- "${flutter_framework}" "${PRODUCT_APP_PATH}" cp -r -- "${flutter_podspec}" "${PRODUCT_APP_PATH}" local precompilation_flag="" if [[ "$BUILD_MODE" != "debug" ]]; then precompilation_flag="--precompiled" fi # build bundle ${FLUTTER_ROOT}/bin/flutter --suppress-analytics \ --verbose \ build bundle \ --target-platform=ios \ --target="${target_path}" \ --${BUILD_MODE} \ --depfile="${BUILD_PATH}/snapshot_blob.bin.d" \ --asset-dir="${BUILD_PATH}/flutter_assets" \ ${precompilation_flag} if [[ $? -ne 0 ]]; then echo "Failed to build flutter assets" exit -1 fi # copy Assets local product_app_assets_path="${PRODUCT_APP_PATH}/Assets" mkdir -p -- "${product_app_assets_path}" cp -rf -- "${BUILD_PATH}/flutter_assets" "${PRODUCT_APP_PATH}/Assets" # setting podspec # replace: # 'Flutter.framework' # to: # 'Flutter.framework', 'App.framework' #s.resource='Assets/*' sed -i '' -e $'s/\'Flutter.framework\'/\'Flutter.framework\', \'App.framework\'\\\ns.resource=\'Assets\/*\'/g' ${PRODUCT_APP_PATH}/Flutter.podspec echo "Finish build flutter app" }
(3) 複製 Flutter 外掛中的 Native 程式碼
Flutter 使用的各種外掛可能會包含 Native 程式碼,並且這些程式碼已經提供了podspec,可以使用 pod 直接引入。我們要做的就是把外掛的 Native 程式碼拷貝到產物目錄。 Flutter 建立了一個給 Native 註冊外掛的 pod 庫 'FlutterPluginRegistrant',這個也需要拷貝出來, 在 Flutter 工程根目錄下有一個 .flutter-plugins 檔案,檔案內部記錄了外掛的名字和外掛的路徑,格式為 pugin_name=/xx/xx/xx,解析這個檔案就可以得到外掛資訊,程式碼如下:
flutter_copy_packages() { echo "=================================" echo "Start copy flutter app plugin" local flutter_plugin_registrant="FlutterPluginRegistrant" local flutter_plugin_registrant_path=".ios/Flutter/${flutter_plugin_registrant}" echo "copy 'flutter_plugin_registrant' from '${flutter_plugin_registrant_path}' to '${PRODUCT_PATH}/${flutter_plugin_registrant}'" cp -rf -- "${flutter_plugin_registrant_path}" "${PRODUCT_PATH}/${flutter_plugin_registrant}" local flutter_plugin=".flutter-plugins" if [ -e $flutter_plugin ]; then OLD_IFS="$IFS" IFS="=" cat ${flutter_plugin} | while read plugin; do local plugin_info=($plugin) local plugin_name=${plugin_info[0]} local plugin_path=${plugin_info[1]} if [ -e ${plugin_path} ]; then local plugin_path_ios="${plugin_path}ios" if [ -e ${plugin_path_ios} ]; then if [ -s ${plugin_path_ios} ]; then echo "copy plugin 'plugin_name' from '${plugin_path_ios}' to '${PRODUCT_PATH}/${plugin_name}'" cp -rf ${plugin_path_ios} "${PRODUCT_PATH}/${plugin_name}" fi fi fi done IFS="$OLD_IFS" fi echo "Finish copy flutter app plugin" }
(4) 將產物同步到保留產物的伺服器
經過上面的幾個步驟後會生成一個產物目錄,這個目錄下會有幾個二級目錄,每個二級目錄裡都包含一個 podspec 檔案。
也就是說這個產物目錄裡存放的就是 cocoapods 庫,將目錄拷貝到 Native 工程,然後用 pod 'pod_name', :path=>'xx/xxx' 的形式引用就可以了。
有了產物後我們需要一個存放產物的地方, 大家可以去這個地方下載,這一步比較靈活,可以選擇將產物放在git倉庫、http伺服器、ftp伺服器等。我最終選擇將產物壓縮成 zip 上傳到 Maven 上,原因是為了和 Android Flutter 產物放在一個地方,並且 Maven 已成做好的產物版本管理。
Maven上傳程式碼比較簡單,這裡不再贅述,有興趣可以到文末的github倉庫檢視程式碼。
Flutter 工程版本設定是在工程目錄下的 'pubspec.yaml' 檔案,打包指令碼讀取這個檔案來確定產物的版本。
最後這個指令碼使用方式為 ./build_ios.h -m debug ./build_ios.h -m release,上文中沒有提到的一點是隻有 release 模式編譯的包才會上傳的伺服器,debug 只是編譯到產物目錄。
第二步 Native 依賴 Flutter 產物
這部分我們需要實現獲取指定版本 Flutter 工程 release 產物,並整合到 Native 專案,並保留可以除錯 Flutter 工程的能力。
也是來拆分一下指令碼流程:
獲取 Flutter 工程產物
獲取 release 產物
獲取 debug 產物
通過 pod 引入 Flutter 工程產物
(1) 獲取 Flutter 工程產物
上文說到只有 release 產物放在了產物伺服器上,debug 只是編譯到產物目錄。不上傳 debug 的原因是,debug 階段就是開發階段,舉個不太恰當的例子:哪有開發階段就把包上傳 app store 的? 也就代表這 release 的產物和 debug 的產物獲取邏輯不一樣,並且我們的指令碼支援兩種方式的切換,所以在 Podfile 新增如下程式碼:
# 設定要引入的 flutter app 的版本 FLUTTER_APP_VERSION="1.1.1" # 是否進行除錯 flutter app, # 為true時FLUTTER_APP_VERSION配置失效,下面的三項配置生效 # 為false時FLUTTER_APP_VERSION配置生效,下面的三項配置失效 FLUTTER_DEBUG_APP=false # Flutter App git地址,從git拉取的內容放在當前工程目錄下的.flutter/app目錄 # 如果指定了FLUTTER_APP_PATH,則此配置失效 FLUTTER_APP_URL="git:/xxxx.git" # flutter git 分支,預設為master # 如果指定了FLUTTER_APP_PATH,則此配置失效 FLUTTER_APP_BRANCH="master" # flutter本地工程目錄,絕對路徑或者相對路徑,如果有值則git相關的配置無效 FLUTTER_APP_PATH="../my_flutter" eval(File.read(File.join(__dir__, 'flutterhelper.rb')), binding)
Podfile 其實就是 Ruby 程式碼,上面幾個由大寫字母組成的變數是全域性變數,最後一句程式碼的作用為讀取'flutterhelper.rb'裡的程式碼並執行,在'flutterhelper.rb'裡可以獲取到上面定義的全域性變數,根據這幾個變數做不同的操作,其中選擇使用 release 還是 debug 的程式碼如下:
if FLUTTER_DEBUG_APP.nil? || FLUTTER_DEBUG_APP == false # 使用 flutter release 模式 puts "開始安裝 release mode flutter app" install_release_flutter_app() else # 存在debug配置,使用 flutter debug 模式 puts "開始安裝 debug mode flutter app" install_debug_flutter_app() end
install_release_flutter_app為操作 release 產物的函式,install_debug_flutter_app為操作 debug 產物的函式。
處理 release 模式主要就是獲取 release 產物,程式碼如下:
# 安裝正式環境環境app def install_release_flutter_app if FLUTTER_APP_VERSION.nil? raise "Error: 請在 Podfile 裡設定要安裝的 Flutter app 版本 ,例如:FLUTTER_APP_VERSION='1.0.0'" else puts "當前安裝的 flutter app 版本為 #{FLUTTER_APP_VERSION}" end # 存放產物的目錄 flutter_release_path = File.expand_path('.flutter_release') # 是否已經存在當前版本的產物 has_version_file = true if !File.exist? flutter_release_path FileUtils.mkdir_p(flutter_release_path) has_version_file = false end # 存放當前版本產物的目錄 flutter_release_version_path = File.join(flutter_release_path, FLUTTER_APP_VERSION) if !File.exist? flutter_release_version_path FileUtils.mkdir_p(flutter_release_version_path) has_version_file = false end # 產物包 flutter_package = "flutter.zip" flutter_release_zip_file =File.join(flutter_release_version_path, flutter_package) if !File.exist? flutter_release_zip_file has_version_file = false end # 產物包下載完成標誌 flutter_package_downloaded = File.join(flutter_release_version_path, "download.ok") if !File.exist? flutter_package_downloaded has_version_file = false end if has_version_file == true # 解壓 flutter_package_path = unzip_release_flutter_app(flutter_release_version_path, flutter_release_zip_file) # 開始安裝 install_release_flutter_app_pod(flutter_package_path) else # 刪除老檔案 FileUtils.rm_rf(flutter_release_zip_file) # 刪除標誌物 FileUtils.rm_rf(flutter_package_downloaded) # 下載 download_release_flutter_app(FLUTTER_APP_VERSION, flutter_release_zip_file, flutter_package_downloaded) # 解壓 flutter_package_path = unzip_release_flutter_app(flutter_release_version_path, flutter_release_zip_file) # 開始安裝 install_release_flutter_app_pod(flutter_package_path) end end
unzip_release_flutter_app為解壓zip格式產物的函式,download_release_flutter_app為從 Maven 下載產物的函式,這兩個比較簡單,詳細程式碼請看文末 github 倉庫。install_release_flutter_app_pod為通過 pod 將產物新增到 Native 的函式,後面會詳細介紹。
處理 debug 模式的操作為,獲取 Flutter 工程原始碼,執行 build_ios.sh -m debug 進行打包,然後得到 debug 產物目錄,詳細程式碼如下:
# 安裝開發環境app def install_debug_flutter_app puts "如果是第一次執行開發環境Flutter專案,此過程可能會較慢" puts "請耐心等️待:coffee:️️️️️:coffee:️:coffee:️\n" # 預設Flutter App 目錄 flutter_application_path = __dir__ + "/.flutter/app" flutter_application_url = "" flutter_application_branch = 'master' # 指定了FLUTTER_APP_PATH就用原生代碼,複製從git拉取 if FLUTTER_APP_PATH != nil File.expand_path(FLUTTER_APP_PATH) if File.exist?(FLUTTER_APP_PATH) flutter_application_path = FLUTTER_APP_PATH else flutter_application_path = File.expand_path(FLUTTER_APP_PATH) if !File.exist?(flutter_application_path) raise "Error: #{FLUTTER_APP_PATH} 地址不存在!" end end puts "\nFlutter App路徑: "+flutter_application_path else if FLUTTER_APP_URL != nil flutter_application_url = FLUTTER_APP_URL if FLUTTER_APP_BRANCH != nil flutter_application_branch = FLUTTER_APP_BRANCH end else raise "Error: 請在'Podfile'裡增加Flutter App git地址配置,配置格式請檢視'flutterhelper.rb'檔案" end puts "\n拉取 Flutter App 程式碼" puts "Flutter App路徑: "+flutter_application_path update_flutter_app(flutter_application_path, flutter_application_url, flutter_application_branch) end puts "\n編譯 Flutter App" # PUB_HOSTED_URL FLUTTER_STORAGE_BASE_URL 為了加快速度,使用國內映象地址 `export PUB_HOSTED_URL=https://pub.flutter-io.cn && \ export FLUTTER_STORAGE_BASE_URL=https://storage.flutter-io.cn && \ cd #{flutter_application_path} && \ #{flutter_application_path}/build_ios.sh -m debug` if $?.to_i == 0 flutter_package_path = "#{flutter_application_path}/.build_ios/debug/product" # 開始安裝 install_release_flutter_app_pod(flutter_package_path) else raise "Error: 編譯 Flutter App失敗" end end
update_flutter_app為從 git 拉取程式碼的函式也不贅述,詳情見文末 github 倉庫。
(2) 通過 pod 引入 Flutter 工程產物
上文兩個函式執行完成後,就得到了產物的存放目錄,下面只需要引入到 Native 倉庫就可以了,也就是install_release_flutter_app_pod函式,從程式碼如下:
# 將 Flutter app 通過 pod 安裝 def install_release_flutter_app_pod(product_path) if product_path.nil? raise "Error: 無效的 flutter app 目錄" end puts "將 flutter app 通過 pod 匯入到 工程" Dir.foreach product_path do |sub| if sub.eql?('.') || sub.eql?('..') next end sub_abs_path = File.join(product_path, sub) pod sub, :path=>sub_abs_path end post_install do |installer| installer.pods_project.targets.each do |target| target.build_configurations.each do |config| config.build_settings['ENABLE_BITCODE'] = 'NO' end end end end
如果要修改 release 產物版本,則設定FLUTTER_APP_VERSION。 如果想要 debug flutter,則設定 FLUTTER_DEBUG_APP=true,如果除錯原生代碼則設定 FLUTTER_APP_PATH="../my_flutter",負責將 FLUTTER_APP_PATH 註釋掉,配置 FLUTTER_APP_URL FLUTTER_APP_BRANCH。
四、總結
對照上文中提到的對混合工程的要求,總結一下:
Flutter 工程完全不依賴 Native 工程,而是通過 'build_ios.sh' 指令碼進行編譯打包;
通過 pod 引入 Flutter 工程對 Native 也沒有浸入,不要在 Native 工程裡增加 Flutter 打包指令碼;
Native 開發工程師只需要執行 pod install 所有的 Flutter 依賴就都加入進工程,不需要工程師配置 Flutter 開發環境;也不影響 Native 打包;
也保留了本地除錯 Flutter 工程的功能;
備註
文章來源於網路如有侵權請及時聯絡本人及時刪除
也許答案不是很專業,希望大家如果有更專業的答案,可以告訴我。順便同步推廣一個iOS交流群:776598941