iOS與Flutter混合開發的姿勢

追風的樹懶發表於2020-07-08

photo from pixabay by freephotocc

首先要解釋一下題目, 本文關於混合開發細節本文會簡要聊一些, 因為官方文件與網友的智慧已經相當完備, 完全可以面向google程式設計, 這裡不必贅述。那麼就回到了本文的核心中來, 主要講述了針對 iOS 與 Flutter 混合開發中為了一個小優化點而進行的一系列不 ( zi ) 懈 ( zuo ) 努 ( zi ) 力 ( shou ) 。

Flutter混合整合模式簡述

說到Flutter混合工程, 原由還是因為這項技術的使用漸進式。在大家的現有業務中使用, 大多數場景是在原有業務中摸索試水, 先用在一個低頻頁面中, 坐觀其變。當看到開發case覆蓋率、crash率、效能、穩定性等一些列指標微微一笑的時候, 加之開發效率提升的加持, 一聲令下要大規模使用, 集結多個團隊, 更多名同學來用的時候, 混合開發模式的優化就迫在眉睫了。

如何讓多名同學協同開發Flutter, 如何對原有工程開發模式的最小侵入, 以及如何快速整合開發和更好的工程化都是Flutter混合開發模式要解決的問題。

官方方案

官方Flutter工程整合至IOS工程, 在這裡能看到Flutter官方是多麼親切的指導這我們來使用這個技術, 其中總共給出裡 ABC 三種方案。

本著不會都選 C 的原則, 選擇這個方案最靠譜。一句話形容就是將Flutter技術的編譯產物 ( Flutter.framework, App.framework等 ), 通過Cocoapods整合至iOS工程中, 它省去了方案 A 的複雜Podfile的修改, 也避免了方案 B 的手動 embed 和 link Flutter產物。

注意 這個方案的Pod引入方式是本地引入, 由於是本地打包, 並不太適合多人協作

業內方案

業內方案就比較簡單了, 基於官方的方案 C, 將Flutter產物釋出到遠端即可, 話不多說, 直接上圖

iOS與Flutter混合開發的姿勢

嘿嘿, 圖片可能大家會覺得好熟悉啊, 這個不是重點, 直觀表達才是。

當然這個方案也不是那麼的簡單, 還是有一些細緻的工作, 包括收集依賴, 處理plugin以及生成Podspec檔案等操作, 需要在一個腳手架的工具中完成這些操作。

基於這個腳手架, 原有IOS工程可以快速無成本的接入flutter的業務模組, 就像引入一個三方庫那麼簡單, 只需要在 Podfile 檔案增加一行即可

pod 'FlutterXXX'
複製程式碼

是不是很開心, 很興奮, 完成了如此重大技術的引入盡在彈指之間。但是在開發 Flutter 的同學就一臉黑了, 開發過程中只能使用 flutter 提供的 demo 工程跑起來, 根本沒有原有 iOS 工程的上下文環境, 沒辦法開發的, 這就是下面要聊的混合工程開發模式。

一鍵整合的思路

想要進行 Flutter 與 iOS 工程混合開發我們需要什麼能力?

  • 能在 IOS 工程中執行 Flutter 專案
  • 執行的 Flutter 專案能夠進行熱更新 ( hot reload )

我理解至少有上面兩項能力就能開心的進行玩耍了, 體會著擁有 Web 開發的體驗, 看著擁有 Native 開發的高效能也是內心歡喜, 還可以不時的幻想著一個人完成 iOS、Android、桌面應用三種產出結果, 貌似就更有成就感了。

於是我們進一步檢視 Flutter 的 Module 型別工程, 會發現谷歌粑粑已經幫我們做好了這一切, 在工程目錄有一個 .ios資料夾, 下面這一個 Ruby 指令碼 podhelper.rb, 一個方法 install_all_flutter_pods 搞定一切。

馬上我們就開始馬不停蹄的來包裝它, 將它整合到我們的腳手架工具中, 為了使用者體驗 ( KPI ) 也是拼了, 最終我們想要的是這樣的 :

程式猿 : 腳手架, 我們想要把 IOS 工程和 Flutter 工程整合在一起
腳手架 : 好的, 給我他們的目錄, 我幫你搞定...
$> integrate iOS_PATH FLUTTER_PATH
$> done 
複製程式碼

經過程式猿的一頓操作猛如虎, 將 Flutter 的混合開發模式能力也整合到了腳手架工具中。這個能力在官方的文件中也有體現, 是需要在 iOS 工程編譯階段插入可執行指令碼, 使用 flutter_tools 中的指令碼生成 Flutter 相關的 framework, 進而可以進行混合開發, 實現 hot reload 功能。

這裡暫時不針對為什麼使用 flutter_tools 指令碼會實現 hot reload 能力的原因展開, 我是不會告訴你我還不知道呢

下面就是如何將 Flutter 整合至 iOS 工程中進行開發的核心邏輯圖 :

iOS與Flutter混合開發的姿勢

有了 Flutter 指令碼在編譯過程中的整合, 原有 iOS 工程就被賦予了針對 Flutter 程式碼 hot reload 的能力, 不得不興奮一小下。

發現的小問題

非 Flutter 開發的同學的混合整合方式以及 Flutter 開發同學的混合開發方式都已經在腳手架中具備了能力, 現在是時候由程式猿大展身手的時候了, 他們快速下意識的開啟了 Xcode 和 VS Code ( Android Studio 也可 )。 正所謂倚天屠龍在手, 誰與爭鋒, 在 Xcode 下熟練的按下了快捷鍵 Command + R , 開始編譯啟動 App, 待 App 順利喚起的時候, 在 Flutter 工程的根目錄下一鍵 flutter attach 命令, 搞定。

當完成了今天的開發任務的時候, 由於開發效率的提升, 程式猿還有一丟丟時間來回顧整個開發過程:

  1. 開啟 Xcode 進行 App 啟動
  2. 開啟 Flutter 工程, 進行工程連結 , 使用flutter attach 命令
  3. 在 Flutter 工程中進行開發

對於 Flutter 開發者, 在多數場景下, 只需要在 Flutter 工程下進行開發即可, IOS工程只有在提供橋接能力的時候, 才會通過 IOS 端上的原生能力進行支援。

那麼, 為什麼不能通過在 Flutter 工程下的 flutter run 命令來啟動開發呢? 這樣的話日常開發步驟就變成了 :

  1. 開啟 Flutter 工程, 使用flutter run 命令啟動 App
  2. 在 Flutter 工程中進行開發

省去了個開啟 iOS 工程的過程, 有沒有更簡單些, 也對非 iOS 開發者比較友好呢 ?

好的, 程式猿收到需求, 準備開工~

分析過程 ( zi zuo )

既然需求已定, 那麼程式猿必使命必達, 完成需求。

首先來看下 Flutter 工程中 module 模式工程的工程目錄 :

├── .android
├── .ios
├── lib
├── pubspec.yaml
複製程式碼

通過目錄結構可以清晰的看到, lib 是 Flutter 相關程式碼, .ios.android 是 native 工程, 那麼 flutter run 命令一定是啟動了這兩個 native 工程, 進一步看下 .ios 目錄結構 :

├── .ios
│   ├── Config
│   ├── Flutter
│   ├── Runner
│   ├── Runner.xcodeproj
│   └── Runner.xcworkspace
複製程式碼

沒錯, 就是它, 熟悉又陌生的 Runner 工程, 下面也會多次提到它。這樣可以推斷出當執行 flutter run 命令的時候, 會使用 xcodebuild 命令找到這個 Runner工程進行編譯啟動, 所以如果能把 Runner 工程替換成我們的專案工程, 理論上就可以了。

經過檢視 Flutter 原始碼可以發現, flutter run 命令只會查詢根目錄下 .iosios 兩個目錄作為啟動工程目錄, 並且優先查詢 ios 目錄, 因為 .ios 目錄會被 flutter clean 命令清除掉, 所以認為有 ios 目錄是開發者的工程目錄, 原始碼片段如下 :

// 原始碼目錄: packages/flutter_tools/lib/src/project.dart 315:324
  Directory get ephemeralDirectory => parent.directory.childDirectory('.ios');
  Directory get _editableDirectory => parent.directory.childDirectory('ios');

  /// This parent folder of `Runner.xcodeproj`.
  Directory get hostAppRoot {
    if (!isModule || _editableDirectory.existsSync()) {
      return _editableDirectory;
    }
    return ephemeralDirectory;
  }	
複製程式碼

進一步檢視原始碼也不難發現, flutter_tools 工具中也對 iOS 工程名稱做了限制 ( 就是hard code), 必須是 Runner 的工程名稱, 以及在後面的 xcodebuild 階段指定的 target 也是 Runner

$> xcodebuild --target Runner
複製程式碼

分析到這裡, 只能說程式猿太難了, 要做這麼多適配工作, 但是, 但是不得不說只是個開始而已...

實現過程 ( zi shou )

經過了大量的分析及實驗之後, 終於有了一份可行性很高的適配指南產出了, 棒棒噠

1. 修改 Podfile 檔案, 注入指令碼
2. 在 Flutter工程根目錄建立 ios 目錄 
3. ios 目錄中有專案工程, 工程名必須是 Runner.xcodeproj
4. 工程中必須有名稱為 Runner 的 target
5. 修改xcodeproj檔案, 適配環境變數及 target 配置
複製程式碼

可以看出來 Flutter 官方的 flutter run 命令是基於將 iOS工程屬於 Flutter工程的一部分來設計的, 並且需要遵循許多 Flutter工程的標準才能不出錯的執行起來。

但是往往事與願違, 我們想要的是專案工程與 Flutter 工程解耦, 貌似只能搬出軟連線的方式了, 例如 :

$> ln -s source target
複製程式碼

這樣既能保證原有的專案工程和 Flutter 工程物理分離開來, 又可以滿足工程改名稱等操作, 於是程式猿將 iOS 工程連結到了 Flutter 工程下的 ios目錄下, 然後進行了一頓操作, 大致的過程如下圖所示 :

iOS與Flutter混合開發的姿勢

從圖中可以看出來, 第二、三步驟花費的精力比較多, 也是整個方案的核心工作。其中修改 Podfile 的工作主要有兩點 :

  1. 複製原有工程 target, 命名為 Runner, 主要是為了能讓 flutter_tools 指令碼找得到我們的工程
  2. 由於是使用軟連結來與 Flutter 工程的關聯, 那麼 flutter_tools 裡面定義的一些路徑會有偏差, 我們需要矯正這些使用到的系統環境目錄

最終修改過的 Podfile 大致是這樣的 :

...
load "Dflu.rb"	# 載入指令碼
target xxx do
...
	dflu_install_flutter_pods do	# 呼叫指令碼安裝依賴
	end
...
end
target Runner do	 # 從 xxx 複製而來, 用於 flutter run 命令呼叫
...
	dflu_install_flutter_pods do	# 呼叫指令碼安裝依賴
	end
...
end
...
複製程式碼

Dflu.rb這個Ruby指令碼做了兩件事情 : 第一是通過解析Flutter工程, 呼叫 CocoaPods 庫的介面來插入 Flutter engine庫, 以及相關plugin等依賴, 第二是適配所有 Flutter 使用的系統環境變數中和工程路徑有關的變數。

修改 xcodeproj 檔案的操作也是比較大的一個工作量, 這一步驟主要是為了建立一個 Runner的 target 來適配 flutter_tools 指令碼的呼叫, 這裡的開發主要還是依賴 Cocoapods 的xcodeproj 原始碼, 這個庫已經幫我們做了許多針對xcodeproj的操作的封裝, 踩在巨人的肩膀上那叫一個爽, 很快就能實現複雜功能。

為了實現程式猿當初許下的諾言 -- 一鍵整合開發的能力, 在他們在腳手架工具中就開發了這樣一條指令 :

$> dflu integrate ios NATIVE_PATH FLUTTER_PATH
複製程式碼

程式猿默默的祭出了這樣一條命令, 心裡是莫名的自豪, 頓時覺得我的程式碼是最好的, 覺得此處應該有掌聲, 然而這只是他的幻想罷了, 還是簡單看下這條命令的大致入口程式碼吧

iOS與Flutter混合開發的姿勢

執行完整合命令, 看到命令列輸出 All Done 的那一刻, 程式猿狠狠的敲下了 flutter run 這條他夢寐以求的命令, 默默點了支菸, 看著 flutter pub get , pod install, xcodebuild, run 等命令的執行, 看似表面很平靜的表情, 其實內心早已焦急不已, 除非沒有任何錯誤報出。

然而, 不是一切盡在程式猿掌握之中, 在專案工程中, 還是有一些特殊情況需要處理, 例如專案工程使用了自定義的 xcconfig 配置, Podfile 檔案中通過 path 方式引入了三方庫等, 都需要一一適配, 只能說程式猿太難了...

實現難點

為了實現這個小優化, 沒想到要做這麼多工作, 整體實現下來不能說有多大的難點, 只是需要反覆不停的實驗以及在 Flutter, Cocospods 原始碼中穿梭。

為了達成當初可以通過 flutter run 進行開發的目標, 也需要考慮原有的直接在 iOS 工程中開發的訴求, 為了適配兩種開發方式, 在能力適配上, 比較多的工作是適配兩種啟動方式的工程路徑問題, 保證兩種啟動方式都能找到正確的工程目錄以及系統環境路徑。

Podfile 指令碼注入

為了能方便的呼叫 Cocoapods 的 API, 腳手架工具也使用了 Ruby 作為開發語言, 在 Podfile 檔案的修改過程中, 主要呼叫了 CocoaPodsCocoaPods Core 的 API, 大致過程包括 :

  • 修改原有工程 pod 引入方式為 path 的, 將 path 路徑修改為絕對路徑
  • 針對 target 新增 script_phases 的自定義指令碼
  • 通過 pod 方式引入 Flutter engine 及相關的庫
  • 引入 ( load ) dflu 指令碼, 並複製原有工程的 target, 新增至 Podfile 尾部並改名為 Runner

這個過程中會不斷的對 CocoaPods 原始碼有了解, 尤其是在對 pod 的 path 修改的時候, 怎麼樣修改已經儲存過的資料, CocoaPods 對 Podfile 的資料是如何儲存的, 都需要一一搞清楚, 然後去修改它, 正如下面的原始碼, 整個Podfile會被儲存到一個 hash 資料結構中 :

# 原始碼目錄 : Core/lib/cocoapods-core/podfile.rb 365:371
    private

    # @!group Private helpers

    # @return [Hash] The hash which store the attributes of the Podfile.
    #
    attr_accessor :internal_hash
複製程式碼

至於 Podfile中的 pod, source, target 等方法都是定義在 Core/lib/cocoapods-core/podfile/dsl.rb 這裡的, 它會將資訊分發儲存到不同的例項中。

xcodeproj 檔案修改

針對xcodeproj檔案進行的修改就只有一個操作, 就是複製一份原有工程的target, 然後命名為 Runner。

這裡要依靠 CocoaPods Xcodeproj 的原始碼, 由於 CocoaPods 的功能也是會對 xcodeproj 檔案進行多維度修改, 所以這個工具庫比較獨立, 可以直接使用。

在複製的過程中. 比較難確定的一點就是具體需要複製哪些資訊, 修改哪些資訊, 這些都需要從 Flutter 工程中自帶的 Runner工程中獲取, 進行不斷的檔案對比, xcodeproj 檔案本質上是一個由 JSON 形式表達的資料結構, 它可以被 Xcode 解析成操作介面, 也可以被解析成記憶體物件, 從下面原始碼可以看出, Xcodeproj 庫就將該檔案解析成記憶體物件 :

# 原始碼目錄 : Xcodeproj/lib/xcodeproj/project.rb 106:114
    def self.open(path)
      path = Pathname.pwd + path
      unless Pathname.new(path).exist?
        raise "[Xcodeproj] Unable to open `#{path}` because it doesn't exist."
      end
      project = new(path, true)
      project.send(:initialize_from_file)
      project
    end
複製程式碼

當然, 這個解析過程還是比較複雜的, 因為這個 JSON 檔案中的物件對應的 Key 都是類似 25E80FB17FE2B7862DABB507 這樣的, 由 xcode 生成的, 而且巢狀層級比較深。

經過不斷的對比和試錯, 程式猿也是最終找到了需要的資訊, 修改步驟如下 :

  1. 新建一個 Runner target, 並新增至工程檔案中
  2. 從原有 target 中複製 Build phases 相關資訊至 Runner, 這裡還要區分 source , resource , framework, shell 等型別, 進行不同的複製操作
  3. 複製 product 資訊
  4. 複製 build configuration list 資訊
  5. 儲存工程檔案

程式猿終於可以長出一口氣了, 自作之路馬上就要結束了, 就剩下一些收尾工作就可以了。

其他適配

在真實的專案中, 可能還會出現很多的專案配置項, 至少目前碰到了一些 :

  • 專案工程使用了自定義的 xcconfig 檔案來配置系統環境變數, 這個時候需要在自定義的 xcconfig 中配置 PODS_ROOT變數的路徑
  • 專案中其他引入了其他的 Flutter 專案工程, 並且也依賴了 Flutter engine 等核心庫, 這樣會造成衝突, 需要協調解決

原始碼除錯

在整體適配過程中, 會檢視幾個庫的原始碼, 也會進行除錯, 在這裡程式猿使用的 VS Code 開關工具, 可謂一個套路走天下, 完全可以覆蓋 Flutter , Shell , Ruby 的開發除錯, 真香~

除錯是使用的 code 的 debugger, 配置的 launch.json 是這樣的 :

  "configurations": [
    {
      "name": "Debug Local File",
      "type": "Ruby",
      "request": "launch",
      "cwd": "${workspaceRoot}",
      "program": "${workspaceRoot}/bin/dflu",
      "args": ["mode", "ios"]
    },
  ]
複製程式碼

除錯 flutter_tools 原始碼的配置如下 :

  "configurations": [
    {
      "name": "Dart",
      "program": "這裡是flutter工程的入口檔案 xxx.dart",
      "request": "launch",
      "type": "dart"
    }
  ]
複製程式碼

有了原始碼除錯能力, 程式猿是有如神助, 不用再猜測, 幻想, 扣程式碼, 為了那個 hash 裡面到底存的是啥而發愁?

問題

當一切都風平浪靜的時候, 程式猿緩緩坐下來, 反思這樣做可能帶來的問題, 不可避免的要說下, 畢竟自己挖的坑自己還是要填的。

  • 在 iOS 中新增或修改 Podfile 的依賴的時候, 由於 Runner target 的存在, 需要同步修改兩部分
  • 腳手架整合是在專案的分支基礎上整合的, 如果專案要切換分支, 需要做現場的復原操作

這樣的利弊分析下來, 貌似利小於弊, 程式猿有些恍惚了, 我都做了什麼。沒事, 我們還可以繼續優化它, 這時內心的另一個聲音出現了。

小結

整篇下來出現頻次最好的當屬 Runner 了, 這個工程啟動的引路人, 為了適配它, 程式猿已經好幾個風黑月高的夜晚不能寐, 抓耳撓腮也經常伴隨, 偶爾還會自問, 這樣做是不是太麻煩了, 原本的操作, 開啟 xcode -> 啟動 App -> flutter attach , 它不香麼?

正如老羅的那本書名一樣 <<生命不息,折騰不止>>, 程式猿也是拼了。

雖然通過這次小優化的折騰, 程式猿確實丟了半條命, 但是也收穫了不少, 比如我們常用的 Podfile 檔案到底是怎麼樣工作的, 在 CocoaPods 裡面我們引入的一些依賴, 包括不同的引入方式是怎麼通過格式化的資料儲存的, 甚至對 CocoaPods 的原始碼架構都有了一些的瞭解, 再比如針對 flutter_tools 這個庫的工作原理, 是如何高效的支援 Flutter 應用的開發協作的, 甚至對 flutter_tools生成的整個 fluttert 命令列工具的原理都有了一些瞭解, 也是小有收穫的。

目前這個腳手架還是屬於 Flutter 混合工程整合與開發的一個工具, 小展望一下在 Flutter 開發生態上的建設道路還很長, 至少有一套 flutter 開發套件來支撐混合整合、開發、engine管理、打包和釋出等一些列工程化工具集, 才能更好的聚焦技術和業務成長。

參考

  1. Flutter 原始碼
  2. CocoaPods 原始碼
  3. CocoaPods Core 原始碼
  4. CocoaPods Xcodeproj 原始碼

相關文章