Flutter 在 iOS 上的一些雷

Flutter Engine 在 iOS 上的一些實作方式,其實會造成在開發 App 時的一些困擾。一是使用 OpenGL 繪製 UI,二是 Flutter 對於觸控事件的處理行為,嗯,用英文來說,有些貪婪(greedy) — 不過這個用法變成中文,語感好像有點怪就是了。

先回到 Flutter 的基本原理:Flutter 讓你用 Dart 語言組成一個樹狀結構的 Model,然後由 Skia 圖形函式庫將這個 Model 繪製出來,在 iOS 上,會再將繪製出來的影像資料,由 Flutter View 呈現出來。

Flutter 使用 OpenGL 造成的問題

如果是在 iOS 模擬器裡頭,Flutter View 內部會使用 CALayer 呈現繪製出來的結果,但如果是實機上,則是使用 CAEAGLLayer;CALayer 與 CAEAGLLayer 的差別在於,CALayer 用來繪製點陣圖形,CAEAGLLayer 則是用來繪製 OpenGL 的圖形資料。

在 iOS 上使用 OpenGL 有一些限制。如果 CAEAGLLayer 所在的 View 不在 App 的最上方 — 像是你把這個 View 的 View Controller 放進 UINavigationController 或 UITabBarController 裡頭,但是這個 View Controller 並不是在 Navigation 的最頂層,或是 Tab Bar 目前選定的分頁;或是你在這個 View 所在的 View Controller 上,又 present 了另外一個 View Controller,讓另外一個 View Controller 蓋在上方 — 或是 App 進入了背景,在這些狀況下,呼叫 CAEAGLLayer 重繪,都不會有作用。這邊可以參考 FlutterView 的程式碼

於是,只要你的 App 有可能從 Flutter 中 present 原生的 iOS View Controller,或是 App 可能會進入背景,都會出現奇怪的行為。

在 iOS 上 Flutter 對於觸控事件的處理,則是實作在 FlutterViewController 當中。Flutter View Controller 的 View 是 Flutter View,Flutter View 只負責繪圖,觸控事件則是 FlutterViewController 實作了 UIResponder protocol 當中跟 UITouch 相關的 delegate method,也就是 touch begin、touch move、touch end… 等等。FlutterViewController 把所有的觸控事件都接收下來,然後 dispatch 到 Flutter Engine 內部的 Widget 系統裡頭。

而這邊 dispatch 的行為預設使用 multi-touch,有幾隻手指放在 Flutter View 上,就處理幾隻手指的觸控事件。比方說,你準備了一個 Flutter 的 ListView Widget,裡頭有多個 ListTile,如果你同時將兩隻手指分別按在兩個 ListTile 上不放,你會發現,兩個 ListTile 都會出現 Material Design 裡頭的水波動畫(Ripple)視覺效果,代表這兩個 ListTile 都被按到。

https://youtu.be/J5vKX8VSowQ

如果按下這兩個 ListTile 點下去之後,行為是使用 url_launcher,present 出 SFSafariViewController 開啟特定的網頁,就會看到靈異現象:同時按著兩個 ListTile,兩者都出現了水波效果,放開其中之一,於是畫面中 present 出另一個 View Controller,於是 FlutterViewController 被蓋在後方,CAEAGLLayer 的繪圖中止,當你把 Safari View Controller 關掉之後,就會看到另一個之前被按著的 ListTile,就處在一種水波動畫卡在當中的狀態,然後你怎麼按都沒辦法把這個狀態關掉。

一種解法是,在 Flutter View Controller 上 present 另一個 View Controller 時,不要讓 Flutter View Controller 真的完全處在被蓋住的狀態,你可以把另一個 View Controller 的modalPresentationStyle ,換成 UIModalPresentationOverFullScreen 或是 UIModalPresentationOverCurrentContext。

至於要怎麼把 url_launcher 這個 Flutter plugin 裡頭的 modalPresentationStyle 換掉?你可以選擇自己 fork 一份 url_launcher,或是就用 method swizzling 把裡頭的一些 method 的實作換掉,反正這個 plugin 在 iOS 這端是用 Objective-C 寫的,你可以快樂使用 Objective-C 的動態特性。

貪婪的觸控行為處理

但如果你寫了自己的其他 plugin,裡頭會在 Flutter View Controller 上方 present 你自己的 View Controller,並且把 modalPresentationStyle 設成 UIModalPresentationOverFullScreen 或是 UIModalPresentationOverCurrentContext,像是 present 一個只有半個畫面的 View Controller,你又可能會看到另外一個靈異現象:你按在你自己的 View Controller 的畫面空白處上,但是觸控事件會穿透了你的 View,傳到了後面的 Flutter View Controller。

我們要來看一下 Responder Chain。話說什麼是 UI?UI 就是一種幻覺,當用戶在觸控螢幕上按到一個按鈕的時候,他不是真的按在一個按鈕上,就只是按在一個冰冷的螢幕上而已,硬體把觸控事件送到作業系統,作業系統再送到位在前景的 App,前景的 App 再一路從這個觸控事件的位置尋找對應的 View 以及該做的事情,最後創造了按在按鈕上的幻覺。而搞得好的幻覺,我們稱之為體驗。

如果這個 View 本身沒有被指派要做的事,沒有實作 UIResponder 裡頭的 method,就去尋找這個 View 的上層,比方說,一個按鈕裡頭有段文字,但是這個文字沒有要做的事,就會往上找這段文字所在的按鈕。iOS 的設計是,如果這個 View 不處理,還會去查詢持有這個 VIew 的 View Controller 有沒有實作 UIResponder,如果也沒有,就往上找 UIWindow、UIApplication 以及 app delegate 是否要處理。

每個可以做事的元件,都叫做 responder。整條查詢誰應該做事的過程,就叫做 Respondeer Chain。

問題就出在,如果我們的 View Controller 沒有實作 UIResponder,但是 present 這個 View Controller 的 View Controller 有,iOS 就會把觸控事件 forward 過去,你不想傳到 Flutter 的事件,也都傳過去了。

比較合理的作法應該是,Flutter 官方應該把 UIResponder 的實作放在 FlutterView 上,然後 FlutterView 透過 delegate 的方式再傳給 FlutterView Controller,以避免 View Controller 之間的事件 forwarding。現在 workaround 的解法是,你可以在自己的 View Controller 上面,做幾個 UIResponder 的空實作,阻斷 forwarding,但情感上,每次留個空實作,都讓人全身不舒服。

背景

而如果你的 App 進入了背景,你又讓 Flutter 呼叫了一些 setState() 要求重繪的工作,這些工作在背景中都不會作用。而且更糟的是,當 App 回到前景後,你繼續呼叫 setState(),也不會重繪,整個畫面就處在一個卡住的狀態。

目前能想到的解法是,既然問題都出在 CAEAGLLayer,那我們就把 iOS 實機上,也都改成讓 Flutter 採用 CALayer。改用 CALayer 之後,畫面的效率會比較差,你可以感受到一些卡頓,但是還沒有卡到讓人用不下去,而一個需要進入背景執行的 App,進出一次前景背景就壞掉,那才是真的用不下去。

我們不會想自己 fork 一份 Flutter Engine 出來…那,就只好搬出 method swizzling 了。我們來寫一個叫做 FlutterViewController+Magic 的 Category,試試看 Objective-C runtime 在載入 FlutterViewController 的時候(FlutterView 沒有被放進 Public Headers 裡頭),把 FlutterView 的 layerClass 換掉。

@import Flutter;
#import <objc/runtime.h>
@interface FlutterViewController (Magic)
@end
@implementation FlutterViewController (Magic)
Class layerClass(id sender, SEL selector, ...) {
return [CALayer class];
}
void viewWillAppear(id sender, SEL selector, BOOL animated, ...) {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wundeclared-selector"
[sender performSelector:@selector(newViewWillAppear:) withObject:@(animated)];
#pragma clang diagnostic pop
[UIApplication sharedApplication].statusBarStyle = UIStatusBarStyleLightContent;
}
+ (void)load
{
[super load];
do {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wundeclared-selector"
SEL oldSelector = @selector(layerClass);
SEL newSelector = @selector(newLayerClass);
#pragma clang diagnostic pop
Class class = object_getClass(NSClassFromString(@"FlutterView"));
class_addMethod(class, newSelector, (IMP)layerClass, @encode(void));
Method oldMethod = class_getInstanceMethod(class, oldSelector);
Method newMethod = class_getInstanceMethod(class, newSelector);
method_exchangeImplementations(oldMethod, newMethod);
} while (0);
}

不過動態特性玩多了,遲早會有報應的…。