原文地址:tech.ebayinc.com/engineering…
原文作者:tech.ebayinc.com/authors/lar…
釋出時間:2021年4月1日
瞭解我們在構建eBay Motors應用時如何避免狀態管理的爭論。
當我們討論eBay Motors應用時,最常被問到的問題是:"eBay Motors使用哪種狀態管理方案?" 簡單的回答是,我們主要是在StatefulWidgets中管理狀態,使用Provider或InheritedWidgets將這個狀態暴露在widget樹下。
然而,我們認為應該問一個更有趣的問題。"eBay Motors是如何設計他們的程式碼庫,使狀態管理工具的選擇不重要?"
我們認為,選擇合適的工具,或者應用單一的設計模式,遠不如在應用程式中的獨特功能和元件之間建立明確的合同和邊界重要。如果沒有邊界,就太容易寫出不可維護的程式碼,而這些程式碼又不能隨著應用的複雜性增長而擴充套件。
"不可維護的程式碼 "可以從幾個方面來定義。
- 一個領域的變化會在看似不相關的領域中產生錯誤。
- 難以推理的程式碼。
- 難以編寫自動測試的程式碼;或
- 程式碼有意外行為。
程式碼中的任何這些問題都會減慢你的速度,並使你更難向使用者提供價值。
在程式碼的不同區域之間建立明確的邊界可以減少這些問題。這種方法鼓勵你將大型的、複雜的問題分解成更小的、更容易管理的碎片。它鼓勵不同的領域通過抽象進行交流,並允許封裝私人的實現細節。它還減少了意外的耦合和副作用,最終導致更靈活的設計,更容易改變。最重要的是,建立這些合同迫使工程師真正理解問題空間和他們正在構建的功能,這總是能產生更好的結果。
我們團隊中的大多數人已經在我們繼承的程式碼庫上合作了好幾年,從經驗來看,我們知道從一開始就新增清晰的領域邊界是很重要的。
作為一個團隊,我們同意開始編纂我們的邊界的最佳方式是為我們應用中的每個主要螢幕建立單獨的Flutter包。這是一個強制功能,有多種目的。首先,它允許我們的團隊成員在不同的螢幕上獨立工作,而不會踩到對方的腳趾。我們希望提供實驗空間,讓工程師發現在新的技術堆疊中,哪種模式最適合我們。其次,它支援我們團隊的目標,即確保所有行為都被測試覆蓋。為了通過持續整合(CI)檢查,每個包都需要被自動化測試完全覆蓋。我們的邊界迫使每個包都是獨立的可測試的,這增加了我們開發時的信心。
這些包的API通常是簡單而直接的。每個包都暴露了一個代表整個螢幕的widget,它將定義實現其目的所需的依賴關係。包中的其他所有東西都是私有的。因此,螢幕的外觀和感覺、使用者互動和狀態管理都是可以自由發展的實現細節,而不會影響到應用程式的其他部分。
當我們開始編碼的時候,我們把代表我們第一套功能的幾個包打了出來。這包括製作一個主螢幕、一個搜尋螢幕和一個車輛詳細資訊螢幕的骨架,用於檢視更多的列表資訊。我們的頂層應用包的重點是將這些包適當地拼接在一起,以建立一個功能性的使用者流。有了這些,我們就可以分工協作,輕鬆並行。
在這一點上,我們的團隊成員繼續測試和學習,在使用Flutter時確定什麼對我們來說是最好的。我們開始在每個包中幾乎只使用BLOC模式,並探索加入我們從傳統的本地開發中習慣的其他設計模式。在整個早期階段,唯一不變的是我們的包邊界和通過每個包的公共API實現100%測試覆蓋率的重點。
幾周後,我們對Flutter的widget樹的理解不斷加深,我們開始認識到,我們應用於狀態管理的模式並沒有為我們提供良好的服務。它們迫使我們建立額外的抽象層,不必要的模板,並使程式碼庫過於複雜。在許多情況下,我們瞭解到,我們可以用一個簡單的StatefulWidget和少得多的程式碼來解決同樣的問題。這時,我們的測試策略的價值就顯現出來了。因為我們是通過包的公共API進行測試的,所以我們的測試並沒有與實現細節相結合,而是專注於對包的行為進行斷言。這使得我們可以無情地重構和交換程式碼層,往往不需要改變一行測試程式碼。
隨著應用規模和複雜度的增長,我們建立的包的數量也越來越多。今天,經過兩年的開發,我們的monorepo由大約24萬行Dart程式碼和5500個測試組成,分佈在80個包中。在過去的24個月裡,我們在狀態管理方面出現了一些模式。
在應用程式初始化過程中,有相當數量的狀態被建立,需要在應用程式的生命週期中繼續存在。我們的Application Package負責初始化並持有對這些狀態的引用,通常在Widget Tree的頂端有一個StatefulWidget。然後,它通過Provider或InheritedWidgets將這些類或行為依賴性注入到widget樹中。
在每個域的包中,經常會有一些狀態被限定在特定的螢幕上。我們在這裡有意不採用一致的模式。每個包都已經發展到使用任何一種適合工作的狀態管理方案。我們已經應用了許多成功的模式(也有不成功的!),包括BLOC、InheritedModel和通過InheritedWidgets暴露Streams和ValueListenables。在許多情況下,我們已經換掉了狀態管理工具,在更多的情況下,我們計劃這樣做。我們的方法是傾聽程式碼,並選擇適合該特定領域需求的最佳工具。這與其說是一個關鍵的架構決策,不如說是一個風格問題。
為了更好地理解我們對包結構所採取的方法,讓我們來看一個例子。
在我們的購買流程中,其中一個關鍵功能是能夠在我們的搜尋螢幕上搜尋車輛,並導航到車輛詳細資訊螢幕以瞭解更多關於車輛的資訊併購買它。
如果我們把這些螢幕分解成最簡單的要求,它們看起來像這樣。
搜尋螢幕
- 與搜尋API整合
- 提供無限滾動的列表
- 提供過濾和排序結果的機制
- 在點選一個列表時,需要導航到另一個螢幕。
汽車美容屏
- 與列表詳細資訊API整合
- 提供有關特定列表的豐富內容
- 需要導航到其他螢幕,以便在列表上進行交易(聊天、購買、出價等)。
這兩個螢幕給人的感覺是截然不同的,並且有非常不同的改變原因。它們是理想的候選者,需要通過明確的邊界來分離。
讓我們先為搜尋螢幕的合同建模。為了使這個螢幕能夠獨立測試,應該注入兩個依賴關係。在這個例子中,我們選擇將這些依賴注入到一個InheritedWidget中,這個InheritedWidget比我們的Search Screen小元件更靠近小元件樹的根部。
class SearchDependencies extends InheritedWidget {
const SearchDependencies({
@required this.searchApi,
@required this.onSearchResultTapped,
@required Widget child,
}) : super(child: child);
final Iterable<SearchResult> Function(SearchParameters, int offset, int limit) searchApi;
final void Function(BuildContext context, String listingId) onSearchResultTapped;
static SearchDependencies of(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<SearchDependencies>();
@override
bool updateShouldNotify(covariant SearchDependencies oldWidget) =>
oldWidget.searchApi != searchApi || oldWidget.onSearchResultTapped != onSearchResultTapped;
}
複製程式碼
注意:在這個例子中,我們只注入了一些依賴關係。在我們的應用程式中,我們將許多依賴項注入到我們的領域包中,如分析報告的API、功能標誌、平臺API等。
這使得Search包中的任何widget都可以使用BuildContext來訪問這些依賴項。SearchDependencies.of(context)。這在概念上與訪問Theme.of(context)或任何其他內建的InheritedWidgets沒有區別。
class SearchResultCard extends StatelessWidget {
const SearchResultCard({@required this.listingId});
final String listingId;
@override
Widget build(BuildContext context) {
return Card(
child: InkWell(
onTap: () => SearchDependencies.of(context).onSearchResultTapped(context, listingId),
child: Column(
children: [
// some UI here
],
),
),
);
}
}
複製程式碼
從測試的角度來看,我們可以簡單地注入給定測試用例所需要的任何假造實現,並且可以完全測試Search Screen包的行為。
return SearchDependencies(
searchApi: (searchParams, offset, pageSize) => [ /* Stub data here */ ],
onSearchResultTapped: (context, listingId) => { /* Stubbed implementation here */},
child: SearchResultsScreen(),
);
複製程式碼
雖然我們有時會使用這種策略來對單個widget進行單元測試,但我們通常會測試包的頂層公共widget。這有助於確保我們的測試驗證整體行為,而不是耦合到實現細節上。我們甚至使用這種策略來提供模擬資料來執行全屏截圖測試。你可以在我們之前的博文中閱讀更多的內容。用Flutter進行截圖測試。
這種依賴性管理的方法還有其他好處。我們在車輛細節螢幕上也使用了同樣的方法。雖然主要的用例是渲染一個實時的eBay列表,但我們的銷售流程中有一個功能,允許賣家在釋出之前預覽他們的列表。通過為該用例包裝具有不同依賴性的Vehicle Detail widget,可以輕鬆實現該預覽功能。
現在我們已經有了搜尋螢幕包的公共API,讓我們看看如何在我們的應用程式中整合它。
class _AppState extends State<App> {
ApiClient apiClient = EbayApiClient();
AppNavigator navigator = AppNavigator();
@override
Widget build(BuildContext context) {
return dependencies(
[
(app) => SearchDependencies(
searchApi: apiClient.search,
onSearchResultTapped: navigator.goToVehicleDetails,
child: app,
),
(app) => VehicleDetailDependencies(
vehicleApi: apiClient.getVehicleDetails,
child: app,
),
],
app: MaterialApp(
localizationsDelegates: [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
SearchResultsLocalizations.delegate,
VehicleDetailsLocalizations.delegate,
],
home: SearchResultsScreen(),
),
);
}
}
複製程式碼
在這個簡單的例子中,一個有狀態的widget存在於我們的Application包中,並構造了我們API客戶端的具體實現。這是我們應用程式的根部件之一,負責構造我們的 MaterialApp。注意,我們正在配置依賴關係,並將它們放在MaterialApp的上方。這是至關重要的,因為MaterialApp提供了我們的根導航器。這意味著當我們導航到新的路線時,這些相同的依賴關係仍然可以從上下文中獲得,因為它們在widget樹的主幹上。
然後,我們將在我們的應用包中新增一個簡單的整合測試,以驗證我們已經正確連線了我們的包。
testWidgets('Should navigate from Search Results to Vehicle Details when I click on a result', harness((given, when, then) async {
final app = AppPageObject();
// Pump the App and assert we are on the Search Screen
await given.appIsPumped();
// Assert the Search Results is on screen
then.findsOneWidget(app.searchResults);
// Assert no Vehicle Details is on screen
then.findsNothing(app.vehicleDetails);
// Tap on the first search result to see its details
await when.userTaps(app.searchResults.resultAt(0));
// Assert no Search Results is on screen
then.findsNothing(app.searchResults);
// Assert the Vehicle Details is on screen
then.findsOneWidget(app.vehicleDetails);
}));
複製程式碼
你可能還注意到,每個包都暴露了自己的本地化委託。為了使每個包能夠獨立地進行測試,包需要完全擁有它的所有資源:影像、字型和本地化字串。
很明顯,這個例子已經被嚴重簡化了。如果從表面上看,這種包結構可能會顯得過分。然而,在我們的程式碼庫中,我們的Search Screen包已經發展到17000行程式碼和500多個測試--它已經足夠大了,以至於我們正在積極努力將其分解成更小的、更容易管理的部分。在實踐中,這個包的邊界使得從事其他功能的開發人員可以完全忽略所有這些複雜性。同樣地,當有人確實需要進行搜尋時,他們能夠只在搜尋螢幕包中工作,而忽略整個應用程式的其他部分。
這種方法為我們以可管理的方式擴充套件程式碼庫提供了一個基礎。我們可以輕鬆地同時開發多個大規模的功能,而且摩擦最小。在一個較小的包中工作,可以集中精力,提高開發人員的週轉時間。如果開發人員做了改變,他們只需要在受影響的包中重新執行測試,如果他們的改變影響到公共API,則偶爾在應用包中執行測試。
我們也完全避免了單子和全域性狀態,總是通過widget樹來管理我們的狀態。正因為如此,Flutter的有狀態的熱過載在整個應用程式中始終如一地工作。它使我們能夠在應用內開發者選單中新增選項,以切換到我們的QA環境--迫使所有依賴API的狀態被丟棄,並使整個應用無縫切換環境而無需重新編譯。這使得我們能夠避免新增特定環境的構建風味。我們唯一的編譯時變化是一個可選的構建引數,以包含開發者選單。
有了解耦的包也給我們的CI管道帶來了巨大的好處。如果我們要對倉庫中的所有程式碼進行構建、分析和測試,需要花費20多分鐘。然而,由於每個包都是獨立的可測試的,我們已經優化了我們的CI管道,以便在我們的構建伺服器上並行構建和測試包。我們更進一步,對於我們的拉取請求(PR),我們只構建和測試那些受受影響檔案影響的包。我們通過評估哪些包是依賴於變化後的包來實現的。這意味著對於大多數拉取請求,我們的CI週轉時間通常在5分鐘以內。然而,這並不是免費的,它需要不斷的迭代和優化。如果你計劃擁有一個具有多個包的單體,你應該計劃在開發者工具和CI自動化上投入一些時間。
獲得正確的包邊界並不總是容易的。我們走過的例子是切入式的,但如果是跨越多個包的複雜功能,問題就會變得更加微妙。考慮一個功能,使用者可以在搜尋結果螢幕和車輛詳細資訊螢幕上 "喜歡 "或 "收藏 "一輛車。有時我們直到功能開發後期才發現正確的包邊界。重新設計這些邊界需要時間和精力,而且很容易把事情踢到一邊,推遲清理。把可重用的程式碼塞進通用、共享或實用程式包中也是很有誘惑力的。然而,"簡單 "的方式幾乎總是導致技術債務的累積。長期以來,我們一直拒絕建立單一用途包,因為我們錯誤地認為增加更多的包是不好的。我們終於超越了這一假設,此後幾乎完成了對最後一個垃圾站包的分解,簡直不能再高興了。
對於我們團隊來說,將領域建模分解並應用到我們的應用中,遠比選擇合適的狀態管理工具更重要。狀態管理的潮流來來去去,但如果你的應用要生存下去,建模是永遠的。
通過www.DeepL.com/Translator(免費版)翻譯