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