0%

【文章翻譯】Extreme UI Adaptability in Flutter — How Google Earth supports every use case on earth

【文章內容使用 Gemini 1.5 Pro 自動產生】

Flutter極致UI適應性:Google Earth如何支援地球上所有用例

當Google Earth準備以Flutter重寫其行動和網頁客戶端時,他們知道他們希望讓所有使用者都能以他們喜歡的方式探索地球,無論他們使用的是哪種設備。這在一定程度上一直是他們的目標;畢竟Google Earth已經擁有現有的網頁、桌面、Android和iOS客戶端。但這次重寫,將涵蓋除了桌面之外的所有目標,需要支援現有用例的超集;再加上Earth團隊渴望探索的一些新的適應性想法。

Google Earth未來技術堆栈的搜尋,受到了他們現有客戶端發展速度緩慢的摩擦來源的嚴重影響。也就是說,Google Earth很早就被迫在開發新功能的速度,以及在三個獨特的程式碼庫(網頁、Android和iOS)中維持功能一致性之間做出選擇。幸運的是,UI的中心——螢幕中央的整個淡藍色點——是由一個C++引擎驅動的,該引擎已經為Google Earth的一些功能提供了統一的體驗。然而,其餘的UI邊框和選單是在每個程式碼庫中單獨實現的。這意味著任何跨平台的選擇,不僅需要徹底改革UI開發流程,還需要與Android、iOS和網頁中的一個大型遺留引擎整合。

選擇使用Flutter的決定因素是雙重的。首先,使用方法通道與現有的Google Earth引擎整合證明是一項簡單的任務。其次,Google Earth不僅希望簡化他們的程式碼庫,還希望徹底重新設計他們的UI。任何重大的UI改版本身就是一種重寫,Google Earth選擇寫一個新的Flutter應用程式,而不是對三個現有應用程式進行手術。這使任務更加複雜,但團隊承諾要徹底改變,並專注於適應性。最後,Google Earth團隊使用Flutter來為三個平台提供UI。

定義適應性

於是,Google Earth團隊踏上了一段旅程,致力於突破UI適應性的極限。在創造符合不同使用者流程的UI方面,先前的經驗比比皆是——回溯到智慧型手機的黎明時期,以及整個網路集體意識到大多數網站都需要針對小螢幕重新設計。瀏覽器API和CSS模式出現了,以構建能夠感知螢幕解析度的網站;這些想法從那以後一直很突出。甚至在Flutter的最早期,開發人員都知道手機螢幕會有所不同,並讓他們應用程式的UI依賴於螢幕的解析度。如果解析度發生變化——無論是使用者旋轉手機還是調整瀏覽器視窗的大小,應用程式的UI都會做出反應。在Flutter中,就像在它之前的網頁中一樣,響應式UI 改善了使用者體驗。

那麼,您可能會好奇,響應式UI和適應性UI之間有什麼區別?簡而言之,響應式UI會根據可用像素的數量和長寬比調整;而適應性UI則會根據所有其他因素進行調整。響應式UI可以根據螢幕空間詳細資訊調整個別UI元素的大小,但適應性UI會回答更根本的問題,例如在哪裡渲染應用程式的導航、列表視圖是否應該導航到單獨的詳細視圖,還是與列表本身並排顯示,以及使用者連接的周邊設備如何影響點擊目標和懸停狀態等因素(稍後將詳細介紹此概念)。

若要進一步了解,請觀看 [Decoding Flutter 的第 15 集](https://youtu.be/HD5gYnspYzk?si=8AvuBRGXNRNET9dR) 關於適應性與響應式UI,並查看來自 [Flutter](https://docs.flutter.dev/ui/layout/responsive/building-adaptive-apps) 和 [Android 文件](https://developer.android.com/develop/ui/views/layout/responsive-adaptive-design-with-views)的這些指南。

任何曾經為網站撰寫響應式CSS的人都會告訴您,即使是最簡單的UI也可能帶有棘手的邊緣情況。明確地說,這不是CSS的錯;問題空間的許多狀態都非常細緻,幾乎感覺像模擬的。那麼,在考慮幾個額外的變數(例如設備形式因素和連接的周邊設備)時,UI開發人員應該期待什麼?當然,他們應該期待複雜性適當增加。

當一個早期原型表現出意外的行為時,這一切就都集中起來了。在玩那個早期版本時,一位Google Earth工程師將他們的桌面網頁瀏覽器縮小到一個非常窄的寬度。突然之間,常見的桌面功能(例如側邊導航欄和較小的點擊目標)被行動功能所取代,例如底部導航欄和更大、更適合手指的按鈕。他們的驚訝是短暫的——畢竟,這正是他們告訴他們的應用程式要做的。Google Earth團隊現在面臨一個深刻的問題——這是一個使用者想要的嗎?

這正是Google Earth團隊即將探索的未知領域。

為什麼要適應性?

對某些人來說,以下內容提出了一個元問題:為什麼要費盡心思做這些事情?當響應式UI肯定能滿足大多數使用者時,投資報酬率是否足夠?

這些都是好問題,但它們不應該導致對Flutter猶豫不決。使用像Flutter這樣的跨平台UI框架並不會引入適應性UI問題;它解鎖了適應性UI解決方案。除此之外,以下兩個因素表明適應性UI確實非常重要:

  • 螢幕解析度不再像以前那樣有意義。桌面瀏覽器可能具有低DPI設定,而基本的斷點檢查會將其與行動環境混淆;橫向模式下的高DPI手機可能會被誤認為是舊的平板電腦(甚至桌面!)斷點;而可摺疊設備可以交替顯示您的應用程式全螢幕,以及在多個應用程式之間分割螢幕空間,如果這導致使用者在某些斷點之間來回切換,就會導致令人不快的差異。
  • 帶有明顯創建與使用模式的應用程式(想想任何具有閱讀和編輯體驗的文字編寫應用程式)在行動裝置上可能會嚴重受到影響——特別是在平板電腦上。將以行動裝置為中心的體驗(可能以使用為中心)發佈到智慧型手機和平板電腦上,極大地限制了具有平板電腦、藍牙鍵盤和滑鼠的強大使用者。

實現適應性

Google Earth團隊經歷了漫長的實驗、使用者研究和迭代過程,才完成了最終發佈的應用程式。但最終,他們的問題空間歸結為三個高級問題:

  1. 應用程式如何確定其初始UI策略?
  2. 應用程式如何以及何時應更改其UI策略?
  3. Google Earth團隊如何清晰地實作此邏輯?

確定初始UI策略

Earth團隊的早期假設之一是「具有觸控螢幕的Chromebook與具有連接藍牙鍵盤的平板電腦之間沒有區別」,並且他們的UI不應該區分這兩種設備。雖然這個想法最初是有道理的,但它在測試中沒有存活下來;隨著時間的推移,Earth團隊越來越意識到這種方法的不足之處。以橫向模式啟動應用程式的使用者,可能會發現自己處於桌面UI的像素解析度範圍內(遵循舊的響應式UI規則)。如果同一位使用者隨後將他們的平板電腦旋轉成縱向模式,並因此轉移到分配給平板電腦的像素解析度範圍,Google Earth將面臨一個艱難的選擇。動態選項將是透過從桌面UI轉換到行動UI來大幅重組所有內容;而靜態選項將是除了將桌面UI壓縮和壓縮,直到它適合新的約束條件之外,什麼都不做。這兩種選項都不令人滿意,這意味著具有觸控螢幕的Chromebook和具有鍵盤的平板電腦之間確實有差異。

最終,Earth團隊設定了一個簡單的規則:為智慧型手機和平板電腦提供行動體驗,為桌面提供桌面體驗。如果這看起來很平淡無奇,那麼,它確實是;但這僅僅是因為它將一些有趣的部分推遲到了下一個問題——UI的初始策略何時應該 更改?

在使用者會話中更新UI策略

Earth團隊的UI更改的第一個策略,僅僅是已建立的響應式UI規則:在任何低於最低門檻值的解析度上顯示您的行動UI,在接下來的幾百種可能的寬度上顯示您的平板UI(如果您有),最後,在任何其他解析度上顯示您的桌面UI。而且,至關重要的是,當UI由於任何原因跨越了其中一個門檻值時,您會相應地重新渲染應用程式。當然,這個規則集的笨拙讓Google Earth開始了它的極致適應性之旅;因此,團隊放棄這種方法也就不足為奇了。

第二種可能性來自Stadia,一個具有成功的Flutter行動應用程式的Google團隊。(顯然,Stadia作為一種產品沒有存活下來;但这并非因为其行動應用程式缺乏功能!)Stadia的方法是根據最後觸摸的輸入來做出適應性UI決定。拖動電腦游標或按下鍵盤,Stadia就會切換到桌面UI模式。相反,如果您傾斜連接的主機控制器上的搖桿,Stadia就會切換到主機UI模式。但是,雖然這對Stadia來說有道理,但它被證明對Google Earth來說不太合適。一個簡單的案例排除了這種最後觸摸輸入策略:一個平板電腦使用者縮放地圖,然後回到藍牙鍵盤完成輸入內容。沒有使用者會希望在這種簡單的互動過程中發生兩次劇烈的UI轉換,因此使用者的最新輸入不能將Google Earth的UI從行動裝置切換到桌面或反之。

最終,Google Earth團隊設定了第二個非常簡單的規則:在一個會話中保持一致,並且在沒有使用者明確許可的情況下,絕不離開初始UI風格。如前所述,Google Earth會在智慧型手機和平板電腦上顯示其以行動裝置為中心的UI,在桌面電腦上顯示其以桌面為中心的UI;並且它絕不會自作聰明地更改此設定,除非使用者在設定面板中請求更改。

混合式UI狀態

在會話中保持UI一致對Google Earth很有幫助,但這並不是全部。桌面體驗中的UI功能(例如游標懸停效果)在行動裝置上沒有任何對應部分,必須重新設計。將觸控螢幕筆電當作平板電腦使用的使用者,可能會因應用程式未能將重要的懸停效果替換為適合行動裝置的替代方案而完全被阻擋。這種認識表明了一個兩層次的問題和解決方案。Google Earth的UI不僅需要在使用者請求時在行動和桌面體驗之間平滑地切換,而且個別控制項需要同時具有觸控友好的形式滑鼠友好的形式,無論整體策略如何。

最後,Google Earth知道他們在建立什麼。他們的所有研究和迭代只留下實作問題,這些問題歸結為:

  1. 如何管理兩個根本不同的UI之間的轉換,以及
  2. 如何建立支援非典型周邊設備的個別控制項

管理多個UI

簡單來說,建立任何可以在兩個不同體驗之間無縫切換的Flutter應用程式,就像在Widget的build方法中的某處放置以下程式碼一樣簡單:

1
child: mode == Mode.desktop ? DesktopUI() : MobileUI()

但是,這種策略(Google Earth使用的策略)意味著在其他地方需要做一些額外的工作才能完全實現。問題——最初是隱藏的——當任何應用程式狀態儲存在Stateful Widget中時就會浮出水面,因為切換mode變數會完全替換Widget樹,破壞所有狀態物件及其所持有的任何資訊。這個問題分為兩個層次。

要想像第一層,請考慮一個在桌面電腦上有多個面板的螢幕,但在行動裝置上將這些面板重新組織成一個標籤欄體驗。行動裝置使用者將有一個活動標籤,但這個概念在桌面電腦上沒有對應部分。將活動標籤索引儲存在StatefulWidget中(這是Flutter中的慣用方式!),會在透過桌面UI來回切換後,始終將行動裝置使用者的位置重置為預設標籤。解決這個問題的方法是將任何原始應用程式狀態——字串、整數等等——從StatefulWidget中移到您的狀態管理類別中。這樣一來,Widget樹中的任何惡作劇都不能重置關鍵的數值。

問題的第二層來自應用程式狀態,這些狀態很難從Widget樹中提取出來,例如TextEditingController或ScrollController。情況看起來像這樣:您有一個帶有TextField的ListTile,但每次使用者觸摸他們的滑鼠或觸控螢幕時,您都會重新建立該ListTile以適應使用者的最新周邊設備。如果沒有干預,這會導致Flutter破壞包含舊TextField的Widget和Element樹的整個部分,並將任何持有使用者工作的控制器一起帶走。您可能會想將這些視為原始資料(TextEditingController作為字串和ScrollController作為雙精度數),並重複上述解決方案;但控制器太豐富了,無法以這種方式輕鬆序列化(游標位置和文字選擇,誰知道呢?)。

為了解決這個問題,Google Earth使用GlobalKeys來讓框架在重新佈局後「重新為高度限定範圍的Widget指定父元素」。下面的AdaptableTextInput Widget嚴格限定在它的TextField和TextEditingController中。在UI更改重新建立過程中為AdaptableTextInput Widget提供相同的GlobalKey,將使TextEditingController保持有效,從而儲存使用者的工作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class AdaptableTextInput extends StatefulWidget {

// 在這裡提供一個一致的 GlobalKey!
const AdaptableTextInput({super.key, required this.mode});

final Mode mode;

@override
State<AdaptableTextInput> createState() => _AdaptableTextInputState();
}

class _AdaptableTextInputState extends State<AdaptableTextInput> {

final _controller = TextEditingController();
final String helpText = '我說明這個文字輸入!';

@override
Widget build(BuildContext context) {
if (widget.mode == Mode.desktop) {
return Tooltip(
showOnHover: helpText,
child: TextField(controller: _controller),
);
} else if (widget.mode == Mode.mobile) {
return Column(
children: <Widget>[
TextField(controller: _controller),
Text(helpText);
],
);
}
}
}

導航

導航堆栈和應用程式的返回按鈕也需要特別注意。繼續以上面的桌面UI的例子來說,該UI一次顯示多個面板,現在想像一個互補的行動UI,它以堆栈式UI呈現這些面板,並具有向前和向後導航。允許桌面使用行動UI,以及手機使用桌面UI,是Google Earth想要追求的重要適應性理念之一。

UI的網格,顯示桌面設備上的桌面UI和行動裝置UI,以及行動裝置上的桌面UI和行動裝置UI

如果桌面UI使用者在切換到行動UI時位於紅色面板上,返回按鈕將不會自動連接,因為導航堆栈將被重置。這意味著您的桌面UI需要考慮額外的技術資訊,這些資訊僅供行動UI使用,因為行動UI隨時可能被要求接管。

一個桌面設備以兩種不同的模式渲染相同的UI——一種是典型的桌面模式,一種是典型的行動裝置模式

幸運的是,GoRouter 的宣告式路由 API 可以提供幫助。建立兩個單獨的路由宣告,並在使用者切換UI模式時切換到適當的路由。在這種情況下,如果桌面UI在收到啟動行動UI的請求時,已將使用者的最後活動追蹤到紅色面板,則呼叫mobileRouter.go('home/blue/red')將建立一個帶有合成歷史的導航堆栈,允許使用者按下返回按鈕退出紅色螢幕。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
final desktopRouter = GoRouter(
routes: [
GoRoute(
path: '/home',
builder: (context, state) => FourPanels(),
),
],
);

final mobileRouter = GoRouter(
routes: [
// 每个面板一个路由,配置为连接返回按钮
// 如果用户到达其中一个嵌 入面板
GoRoute(
path: '/home/blue',
builder: (context, state) => BluePanel(),
routes: [
GoRoute(
path: 'red',
builder: (context, state) => RedPanel(),
routes: [
GoRoute(
path: 'green',
builder: (context, state) => GreenPanel(),
routes: [
GoRoute(
path: 'yellow',
builder: (context, state) => YellowPanel(),
),
],
),
],
),
],
),
],
);

像Google Earth這樣的適應性很強的UI需要一種實作,將所有可能的情況視為始終處於活動狀態,即使只渲染了特定的UI。這意味著應用程式必須始終能夠從您完全控制的資源中完全重建其狀態——無論是您是否有GlobalKeys來保留持有重要資訊的狀態物件,還是您已將所有相關詳細資訊儲存在狀態管理類別中。

適應使用者輸入

這只留下了一個棘手的適應性問題:確保UI中的控制項能够适应使用者的最后使用的外围设备,而不仅仅是当前有效的UI策略。毕竟,如果一个平板电脑用户开始点击蓝牙鼠标; Google Earth不会将UI整体切换到桌面模式,但他们确实想要稍微调整一些元素,以利用键盘和鼠标的优势。

仅仅使用Flutter意味着Google Earth在这方面已经取得了良好的开端。想象一下另一种情况:一个应用程序跨越三个代码库(JavaScript用于通过网页访问桌面,Swift和Kotlin用于移动设备),当Swift和Kotlin团队意识到如果在某些情况下,他们可以从JavaScript应用程序的UI中借用元素,那就太好了。也许他们需要的可以被简单地重新实现; 也许不行。无论哪种方式; 在一个Flutter应用程序中,您想要借用的现有工具始终位于同一个代码库中。

但是代码共享不是代码组织,如何连贯地实现这个问题仍然存在。在这里,Google Earth团队转向了一个老的Flutter主打工具:构建器模式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/// 用户输入的高级类别。
enum InputType { gamepad, keyboardAndMouse, touch }

/// 构建一个依赖于用户当前[InputType]的widget树。
class InputTypeBuilder extends StatelessWidget {
/// 当[InputType]数据更新时调用。
final Function(BuildContext, InputTypeModel, Widget?) builder;

/// 构建一个包装widget,只要[InputType]
/// 改变就会调用[builder]。
///
/// 有关如何更改[InputType]的详细信息,请参见[InputTypeModel]。
const InputTypeBuilder({
Key? key,
required this.builder,
}) : super(key: key);

@override
Widget build(BuildContext context) {
return Consumer<InputTypeModel>(
builder: (context, inputTypeModel, _) {
return builder(
context,
inputTypeModel.inputType,
);
},
);
}
}

InputTypeBuilder这样的Widget会监听一个顶层机制,即InputTypeModel,它本身会订阅Flutter引擎以接收有关最后使用的输入的更新。InputTypeModel.inputTypeInputType枚举的属性。有了它,子Widget就可以根据用户当前与应用程序的交互方式做出本地化决策,决定如何渲染自身。例如,如果您一直在使用鼠标,但随后用手指点击了触摸屏,以前只有在鼠标悬停时才会出现的可视提示现在会在整个应用程序中显示。同样地,如果您切换回使用鼠标,这个InputTypeBuilder将允许他们撤销更改。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@override
Widget build(BuildContext context) {
return InputTypeBuilder(
builder: (context, inputTypeModel, child) {
final bool isHoveredOrTouchInput = isHovered || inputTypeModel.inputType == InputType.touch;
return Row(
children: <Widget>[
isHoveredOrTouchInput ? DragIndicator() : Container(),
RestOfRow(),
],
);
},
);
}

以下GIF显示了Google Earth 的桌面UI(在Chrome中运行),巧妙地适应了用户在触摸屏和鼠标操作之间切换的操作。

Google Earth 的 UI 在最终用户与不同的外围设备交互时,在典型的桌面和移动可视提示之间切换

结论

使用Flutter重新构建Google Earth带来的最大的意外收获,来自平板电脑和网页等中间环境的使用者。这些设备夹在手机和平板电脑之间,可以在物理上支持两种类型的体验,但很少享有与之匹配的软件灵活性。同样地,网页体验可以在任何设备上加载;在桌面电脑上,浏览器可以任意调整大小。根据应用程序的不同,所有这些都可能意味着截然不同的UI。对于大多数将每个构建目标的代码库分开的开发团队来说,完全支持身处这些中间状态的使用者是不可能的。(想象一下说服你的老板花时间在行动装置上重建你的整个桌面UI,仅仅因为平板电脑使用者想要它!)

但是,正如Google Earth团队所发现的,虽然在一个代码库中构建完全適應性的 UI确实意味着額外的複雜性,但它被满足所有使用者需求所獲得的使用者體驗改進所抵消。

您今天就可以嘗試Google Earth的新Flutter實作,方法是在Android或iOS上下载應用程式,或访问 https://earth.google.com


Flutter極致UI適應性:Google Earth如何支援地球上所有用例 最初發佈在 Flutter 上的 Medium,人們在那裡透過突出顯示和回應這個故事來繼續討論。