用與 UI 測試實踐)
Flutter iOS Host AppAdd to App 集成場景的宿主應(yīng)用與 UI 測試實踐【免費下載鏈接】flutterFlutter makes it easy and fast to build beautiful apps for mobile and beyond項目地址: https://gitcode.com/GitHub_Trending/flutter41/flutter本文基于 Flutter 倉庫中的 dev/integration_tests/ios_host_app 集成測試工程系統(tǒng)講解這個iOS 宿主應(yīng)用host app的設(shè)計意圖與完整實現(xiàn)它如何作為flutter create -t module生成模塊的宿主容器演示 Add to App 的五類核心場景原生基線、全屏冷/熱引擎、混合視圖、雙引擎并行、動態(tài)內(nèi)容縮放并通過 XCUITest 自動化驗證引擎預(yù)熱復(fù)用、平臺通道通信與多引擎并發(fā)等關(guān)鍵能力。讀完后你將掌握在原生 iOS 應(yīng)用中嵌入 Flutter 的完整接入方式以及官方 CIdevicelabmodule_test_ios對該場景的驗證手段。工程定位為 devicelab 模塊測試服務(wù)的宿主應(yīng)用該工程的 README.md 開宗明義Used by themodule_test_ios.dartdevice lab test.即這個宿主應(yīng)用首先是 Flutter 官方 devicelab 持續(xù)集成任務(wù)module_test_ios的被測對象。其結(jié)構(gòu)分三層Host/—— 原生 iOS 宿主應(yīng)用Objective-C包含全部演示用的 ViewControllerflutterapp/—— 通過flutter create -t module hello創(chuàng)建的 Flutter 模塊的lib目錄承載 Dart 側(cè)演示代碼FlutterUITests/—— 基于 XCUITest 的 UI 自動化測試FlutterUITests.m。工程還帶有 Podfile 與 PodfileMissingPostInstall從源碼結(jié)構(gòu)看后者用于在 CI 中模擬Podfile 缺少 Flutter 模板 post_install 鉤子的異常路徑配合module_test_ios任務(wù)做故障注入式驗證。準(zhǔn)備工作創(chuàng)建 Flutter 模塊并接入宿主應(yīng)用README 給出的接入步驟如下用命令創(chuàng)建一個 Flutter 模塊$ flutter create -t module hello將模塊放到宿主應(yīng)用的同級目錄sibling folder中把本倉庫flutterapp/lib/下的marquee.dart與main.dart復(fù)制到新模塊的lib目錄中——這是為了讓平臺單元測試通過因為FlutterUITests斷言的頁面元素如Button tapped 0 times.、This is Marquee來自這些 Dart 文件。當(dāng)前倉庫中的 Dart 側(cè)源碼位于flutterapp/lib/main —— 應(yīng)用入口與路由flutterapp/lib/marquee —— 跑馬燈演示頁marquee_green/marquee_purple兩個路由供雙引擎演示使用flutterapp/lib/resize —— 動態(tài)縮放演示頁resize路由。宿主應(yīng)用首頁六個演示入口首頁 MainViewController.m 以UIStackView垂直排列六個按鈕分別對應(yīng) README 中列出的全部演示場景按鈕目標(biāo)頁面引擎狀態(tài)Native iOS ViewNativeViewController純原生基線Full Screen (Cold)FullScreenViewController新建引擎冷啟動Full Screen (Warm)FullScreenViewController復(fù)用預(yù)置引擎熱引擎Flutter View (Warm)裸FlutterViewController復(fù)用預(yù)置引擎熱引擎Hybrid View (Warm)HybridViewController熱引擎Dual Flutter View (Cold)DualFlutterViewController兩個獨立冷引擎Dynamic Content ResizingDynamicResizingViewController冷啟動首頁同時暴露了兩個便捷訪問器供各子頁面直接取用預(yù)置引擎和 reload 通道- (FlutterEngine *)engine { return [(AppDelegate *)[[UIApplication sharedApplication] delegate] engine]; } - (FlutterBasicMessageChannel*)reloadMessageChannel { return [(AppDelegate *)[[UIApplication sharedApplication] delegate] reloadMessageChannel]; }引擎預(yù)熱AppDelegate 中冷/熱的本質(zhì)冷引擎cold與熱引擎warm的區(qū)別集中在 AppDelegate.m。應(yīng)用啟動時立即創(chuàng)建并運行一個命名引擎static NSString *_kReloadChannelName reload; - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { _engine [[FlutterEngine alloc] initWithName:test project:nil]; [_engine runWithEntrypoint:nil]; _reloadMessageChannel [[FlutterBasicMessageChannel alloc] initWithName:_kReloadChannelName binaryMessenger:_engine.binaryMessenger codec:[FlutterStringCodec sharedInstance]]; return [super application:application didFinishLaunchingWithOptions:launchOptions]; }兩個關(guān)鍵設(shè)計FlutterEngine與FlutterViewController解耦引擎在 App 啟動時即被創(chuàng)建并runWithEntrypoint:與任何 UI 無關(guān)。后續(xù)任何頁面都可以通過initWithEngine:把同一個引擎掛到新的FlutterViewController上這正是pre-warm 引擎并反復(fù) attach/detach ViewController能力的實現(xiàn)基礎(chǔ)。reload通道在引擎的binaryMessenger上建立了一個名為reload的FlutterBasicMessageChannel字符串編解碼用于向 Dart 側(cè)推送目標(biāo)路由。每次進入新頁面前先發(fā)消息如full、/再創(chuàng)建 ViewController保證 Dart 側(cè) UI 提前就緒。對應(yīng)的冷啟動入口showFullScreenCold則不經(jīng)過預(yù)置引擎- (void)showFullScreenCold { FullScreenViewController *flutterViewController [[FullScreenViewController alloc] init]; // 內(nèi)部創(chuàng)建全新引擎 [flutterViewController setInitialRoute:full]; [[self reloadMessageChannel] sendMessage:full]; [self.navigationController pushViewController:flutterViewController animated:NO]; }而熱引擎入口showFullScreenWarm先在引擎的導(dǎo)航通道上設(shè)置初始路由再復(fù)用引擎構(gòu)造頁面- (void)showFullScreenWarm { [[self engine].navigationChannel invokeMethod:setInitialRoute arguments:full]; [[self reloadMessageChannel] sendMessage:full]; FullScreenViewController *flutterViewController [[FullScreenViewController alloc] initWithEngine:[self engine] nibName:nil bundle:nil]; [self.navigationController pushViewController:flutterViewController animated:NO]; }值得注意的是兩處animated:NO源碼注釋說明冷引擎場景下帶轉(zhuǎn)場動畫會出現(xiàn)明顯卡頓Its especially bad with a cold engine。場景一全屏 Flutter 頁面與引擎 detachFullScreenViewControllerFullScreenViewController.m 是FlutterViewController的子類覆蓋全屏并隱藏導(dǎo)航欄與狀態(tài)欄。其核心是引擎的 detach 時機-(void)viewWillDisappear:(BOOL)animated { [super viewWillDisappear:animated]; self.navigationController.navigationBarHidden NO; self.navigationController.hidesBarsOnSwipe NO; if (self.isMovingFromParentViewController) { // If we needed Flutter to tell us when we could actually go away, // wed need to communicate over a method channel with it. [self.engine setViewController:nil]; } }setViewController:nil在確認(rèn)正在被彈出返回導(dǎo)航棧isMovingFromParentViewController時把引擎從當(dāng)前 ViewController 上解綁。源碼注釋特別提示如果頁面使用了image_picker等可能喚起系統(tǒng) UI 的插件此處不能盲目 detach如需判斷何時可以真正離開應(yīng)通過 method channel 與 Dart 側(cè)協(xié)商。這一細(xì)節(jié)體現(xiàn)了引擎生命周期管理在真實業(yè)務(wù)中的邊界條件。場景二混合視圖與平臺通道雙向交互HybridViewControllerHybridViewController.m 演示同一個屏幕上同時存在原生視圖與 Flutter 視圖并用平臺通道讓二者互相通信。布局上用一個等分垂直UIStackView上下各放一半上半部分是NativeViewController作為 childViewController 嵌入delegate 指向自身下半部分是基于預(yù)置熱引擎創(chuàng)建的FlutterViewController。通道部分注意使用的是FlutterBasicMessageChannel 字符串編解碼channel 名為incrementstatic NSString *_kChannel increment; static NSString *_kPing ping; // 從 Flutter 側(cè)的消息通道Dart 點擊后向原生發(fā) ping _messageChannel [[FlutterBasicMessageChannel alloc] initWithName:_kChannel binaryMessenger:_flutterViewController.binaryMessenger codec:[FlutterStringCodec sharedInstance]]; __weak NativeViewController *weakNativeViewController nativeViewController; [_messageChannel setMessageHandler:^(id message, FlutterReply reply) { [weakNativeViewController didReceiveIncrement]; // 原生計數(shù) 1 reply(); }]; // 原生 - Flutter 方向 - (void)didTapIncrementButton { [_messageChannel sendMessage:_kPing]; }交互閉環(huán)是Dart 側(cè) Flutter 按鈕點擊 → 經(jīng)increment通道發(fā)ping→ 原生NativeViewController計數(shù) 1 并刷新文案原生 Increment via iOS 按鈕點擊 → 原生發(fā)ping→ Dart 側(cè)計數(shù) 1。注意__weak弱引用避免了 VC 之間的循環(huán)引用這是宿主應(yīng)用中嵌入 Flutter 時容易踩的坑。嵌入子視圖的標(biāo)準(zhǔn)三部曲也在此體現(xiàn)addChildViewController:→addArrangedSubview:→didMoveToParentViewController:。場景三雙引擎并行DualFlutterViewControllerDualFlutterViewController.m 驗證同一 App 內(nèi)同時運行兩個 Flutter 引擎_topFlutterViewController [[FlutterViewController alloc] init]; _bottomFlutterViewController [[FlutterViewController alloc] init]; [_topFlutterViewController setInitialRoute:marquee_green]; [_bottomFlutterViewController setInitialRoute:marquee_purple];兩次不帶引擎參數(shù)的init會各自創(chuàng)建獨立引擎再通過setInitialRoute:分別路由到marquee_green與marquee_purple兩條跑馬燈頁面。UI 測試 FlutterUITests.m 中testDualCold用謂詞斷言頁面上恰好存在兩個This is Marquee文本元素count 2作為兩個引擎獨立渲染的驗收標(biāo)準(zhǔn)。場景四動態(tài)內(nèi)容縮放DynamicResizingViewControllerDynamicResizingViewController.m 演示 Flutter 視圖作為長列表中的一項參與原生自動布局在一個UIScrollViewUIStackView里排布 50 個原生UILabel第 10 項替換為FlutterViewController_flutterViewController [[FlutterViewController alloc] init]; [_flutterViewController setInitialRoute:resize]; _flutterViewController.autoResizable YES; // 允許隨布局動態(tài)改變尺寸autoResizable YES是關(guān)鍵它讓 Flutter 視圖可以按容器約束改變大小。UI 測試testResize會先斷言flutter_view的初始高度為 100點擊后斷言高度變?yōu)?200通過accessibilityIdentifier flutter_view定位驗證 Flutter 視圖尺寸變化時與原生滾動布局的正確聯(lián)動。自動化驗收FlutterUITests 驗證的三個關(guān)鍵點README 明確列出該工程測試的三件核心事情能預(yù)熱引擎并反復(fù)從引擎 attach/detach ViewController能用平臺通道在視圖之間通信能同時運行兩個引擎實例。FlutterUITests.m 以 XCUITest 逐一覆蓋超時統(tǒng)一 60 秒testFullScreenColdPop/testFullScreenWarm進入全屏頁面后斷言Button tapped 0 times.出現(xiàn)點擊 Flutter 側(cè)Increment via Flutter元素后斷言計數(shù)變?yōu)?1再點POP回到Flutter iOS Demos Home導(dǎo)航欄。冷、熱兩條路徑走的是同一套斷言從而對比驗證新建引擎與復(fù)用預(yù)熱引擎兩種 attach 方式的 UI 正確性testFlutterViewWarm驗證裸FlutterViewControllerchild view 式 push在熱引擎下的交互testHybridViewWarm進入混合頁面后同時斷言 Flutter 側(cè)與 Platform 側(cè)兩個計數(shù)器初始均為 0分別點擊兩個方向的按鈕并驗證對應(yīng)計數(shù)器遞增、另一個不變完整覆蓋雙向通道通信testDualCold用NSPredicate(count 2)斷言兩個跑馬燈元素同時存在testResize斷言 Flutter 視圖高度 100 → 200 的動態(tài)縮放。測試中還體現(xiàn)了真實的健壯性處理例如testFullScreenWarm對首次點擊未生效時會重試一次XCUITest tap 競態(tài)testFlutterViewWarm針對 iOS 17 模擬器偶發(fā)重啟的二次重試與os_log診斷輸出。統(tǒng)一封裝的waitForAndTapElement:先用exists YES AND hittable YES謂詞等待元素可點擊再 tap避免了對未就緒元素的盲點擊。小結(jié)與延伸閱讀該宿主應(yīng)用是理解 Flutter Add to App 原生側(cè)接入方式的精煉樣本其覆蓋的 API 面包括FlutterEngine預(yù)創(chuàng)建、initWithEngine:復(fù)用、setViewController:nil解綁setInitialRoute:與navigationChannel的setInitialRoute方法通道控制初始路由FlutterBasicMessageChannel雙向字符串消息通道多引擎并行與autoResizable動態(tài)布局。如需進一步了解該工程在 CI 中的執(zhí)行方式可參考 devicelab 任務(wù)框架dev/devicelab同目錄下還有 ios_add2app_life_cycle 等 Add to App 相關(guān)集成測試工程可與本文的工程對照閱讀?!久赓M下載鏈接】flutterFlutter makes it easy and fast to build beautiful apps for mobile and beyond項目地址: https://gitcode.com/GitHub_Trending/flutter41/flutter創(chuàng)作聲明:本文部分內(nèi)容由AI輔助生成(AIGC),僅供參考