簡介
Flutter 是 Google 的一套跨平臺 UI 框架。目前已經是 1.7 的 Release 版本。在移動端雙端投入人力較大,短期緊急需求的背景下。跨端技術會成為越來越多的移動端技術棧選擇。銘師堂移動端團隊在過去幾個月,對 Flutter 技術做了一些嘗試和工作。這篇文章將會對 Flutter 的基本原理和我們在 升學e網通 APP
的工程實踐做一個簡單的分享。
Flutter 的架構和原理
Flutter framework 層的架構圖如下:
Foundation: foundation 提供了 framework 經常使用的一些基礎類,包括但不限於:
-
BindBase: 提供了提供單例服務的物件基類,提供了 Widgets、Render、Gestures等能力
-
Key: 提供了 Flutter 常用的 Key 的基類
-
AbstractNode:表示了控制元件樹的節點
在 foundation 之上,Flutter 提供了 動畫、繪圖、手勢、渲染和部件,其中部件就包括我們比較熟悉的 Material 和 Cupertino 風格
我們從 dart 的入口處關注 Flutter 的渲染原理
void runApp(Widget app) {
WidgetsFlutterBinding.ensureInitialized()
..attachRootWidget(app)
..scheduleWarmUpFrame();
}
複製程式碼
我們直接使用了 Widgets 層的能力
widgets
負責根據我們 dart 程式碼提供的 Widget 樹,來構造實際的虛擬節點樹
在 FLutter 的渲染機制中,有 3 個比較關鍵的概念:
- Widget: 我們在 dart 中直接編寫的 Widget,表示控制元件
- Element:實際構建的虛擬節點,所有的節點構造出實際的控制元件樹,概念是類似前端經常提到的 vitrual dom
- RenderObject: 實際負責控制元件的檢視工作。包括佈局、渲染和圖層合成
根據 attachRootWidget
的流程,我們可以瞭解到佈局樹的構造流程
attachRootWidget
建立根節點attachToRenderTree
建立 root Element- Element 使用
mount
方法把自己掛載到父 Element。這裡因為自己是根節點,所以可以忽略掛載過程 mount
會通過createRenderObject
建立 root Element 的 RenderObject
到這裡,整顆 tree 的 root 節點就構造出來了,在 mount
中,會通過 BuildOwner#buildScope
執行子節點的建立和掛載, 這裡需要注意的是 child 的 RenderObject 也會被 attach 到 parent 的 RenderObejct 上去
整個過程我們可以通過下圖表示
感興趣可以參考 Element
、RenderObjectElement
、RenderObject
的原始碼
渲染
負責實際整個控制元件樹 RenderObject 的佈局和繪製
runApp 後會執行 scheduleWarmUpFrame
方法,這裡就會開始排程渲染任務,進行每一幀的渲染
從 handleBeginFrame
和 handleDrawFrame
會走到 binding 的 drawFrame
函式,依次會呼叫 WidgetsBinding
和 RendererBinding
的 drawFrame
。
這裡會通過 Element 的 BuildOwner
,去重新塑造我們的控制元件樹。
大致原理如圖
在構造或者重新整理一顆控制元件樹的時候,我們會把有改動部分的 Widget 標記為 dirty,並針對這部分執行 rebuild,但是 Flutter 會有判斷來保證儘量複用 Element,從而避免了反覆建立 Element 物件帶來的效能問題。
在對 dirty elements 進行處理的時候,會對它進行一次排序,排序規則參考了 element 的深度:
static int _sort(Element a, Element b) {
if (a.depth < b.depth)
return -1;
if (b.depth < a.depth)
return 1;
if (b.dirty && !a.dirty)
return -1;
if (a.dirty && !b.dirty)
return 1;
return 0;
}
複製程式碼
根據 depth 排序的目的,則是為了保證子控制元件一定排在父控制元件的左側, 這樣在 build 的時候,可以避免對子 widget 進行重複的 build。
在實際渲染過程中,Flutter 會利用 Relayout Boundary機制
void markNeedsLayout() {
// ...
if (_relayoutBoundary != this) {
markParentNeedsLayout();
} else {
_needsLayout = true;
if (owner != null) {
owner._nodesNeedingLayout.add(this);
owner.requestVisualUpdate();
}
}
//...
}
複製程式碼
在設定了 relayout boundary 的控制元件中,只有子控制元件會被標記為 needsLayout,可以保證,重新整理子控制元件的狀態後,控制元件樹的處理範圍都在子樹,不會去重新建立父控制元件,完全隔離開。
在每一個 RendererBinding 中,存在一個 PipelineOwner
物件,類似 WidgetsBinding 中的 BuildOwner
. BuilderOwner
負責控制元件的build 流程,PipelineOwner
負責 render tree 的渲染。
@protected
void drawFrame() {
assert(renderView != null);
pipelineOwner.flushLayout();
pipelineOwner.flushCompositingBits();
pipelineOwner.flushPaint();
renderView.compositeFrame(); // this sends the bits to the GPU
pipelineOwner.flushSemantics(); // this also sends the semantics to the OS.
}
複製程式碼
RenderBinding 的 drawFrame
實際闡明瞭 render obejct 的渲染流程。即 佈局(layout)、繪製(paint)、合成(compositeFrame)
排程(scheduler和執行緒模型)
在佈局和渲染中,我們會觀察到 Flutter 擁有一個 SchedulerBinding
,在 frame 變化的時候,提供 callback 進行處理。不僅提供了幀變化的排程,在 SchedulerBinding
中,也提供了 task 的排程函式。這裡我們就需要了解一下 dart 的非同步任務和執行緒模型。
dart 的單執行緒模型,所以在 dart 中,沒有所謂的主執行緒和子執行緒說法。dart 的非同步操作採取了 event-looper 模型。
dart 沒有執行緒的概念,但是有一個概念,叫做 isolate, 每個 isolate 是互相隔離的,不會進行記憶體的共享。在 main isolate 的 main 函式結束之後,會開始一個個處理 event queue 中的 event。也就是,dart 是先執行完同步程式碼後,再進行非同步程式碼的執行。所以如果存在非常耗時的任務,我們可以建立自己的 isolate 去執行。
每一個 isolate 中,存在 2 個 event queue
- Event Queue
- Microtask Queue
event-looper 執行任務的順序是
- 優先執行 Microtask Queue 中的task
- Microtask Queue 為空後,才會執行 Event Queue 中的事件
flutter 的非同步模型如下圖
Gesture
每一個 GUI 都離不開手勢/指標的相關事件處理。
在 GestureBiding 中,在 _handlePointerEvent
函式中,PointerDownEvent
事件每處理一次,就會建立一個 HintTest
物件。在 HintTest
中,會存有每次經過的控制元件節點的 path。
最終我們也會看到一個 dispatchEvent
函式,進行事件的分發以及 handleEvent
,對事件進行處理。
在根節點的 renderview 中,事件會開始從 hitTest
處理,因為我們新增了事件的傳遞路徑,所以,時間在經過每個節點的時候,都會被”處理“。
@override // from HitTestDispatcher
void dispatchEvent(PointerEvent event, HitTestResult hitTestResult) {
if (hitTestResult == null) {
assert(event is PointerHoverEvent || event is PointerAddedEvent || event is PointerRemovedEvent);
try {
pointerRouter.route(event);
} catch (exception, stack) {
}
return;
}
for (HitTestEntry entry in hitTestResult.path) {
try {
entry.target.handleEvent(event, entry);
} catch (exception, stack) {
}
}
}
複製程式碼
這裡我們就可以看出來 Flutter 的時間順序,從根節點開始分發,一直到子節點。同理,時間處理完後,會沿著子節點傳到父節點,最終回到 GestureBinding
。
這個順序其實和 Android 的 View 事件分發 和 瀏覽器的事件冒泡 是一樣的。
通過 GestureDector
這個 Widget, 我們可以觸發和處理各種這樣的事件和手勢。具體的可以參考 Flutter 文件。
Material、Cupertino
Flutter 在 Widgets 之上,實現了相容 Andorid/iOS 風格的設計。讓APP 在 ui/ue 上有類原生的體驗。
Flutter 的工程實踐
根據我們自己的實踐,我從 混合開發、基礎庫建設和日常的採坑的角度,分享一些我們的心得體會。
混合工程
我們的 APP 主題大部分是 native 開發完成的。為了實踐 Flutter,我們就需要把 Flutter 接入到原生的 APP 裡面去。並且能滿足如下需求:
- 對不參與 Flutter 實踐的原生開發同學不產生影響。不需要他們去安裝 Flutter 開發環境
- 對於參與 FLutter 的同學來說,我們要共享一份dart 程式碼,即共享一個程式碼倉庫
我們的原生架構是多 module 元件化,每個 module 是一個 git 倉庫,使用 google git repo 進行管理。以 Android 工程為例,為了對原生開發沒有影響。最順理成章的思路就是,提供一個 aar 包。對於 Android 的視角來說,flutter 其實只是一個 flutterview,那麼我們按照 flutter 的工程結構自己建立一個相應的 module 就好了。
我們檢視 flutter create
建立的flutter project的Andorid的 build.gradle
,可以找到幾個關鍵的地方
app的build.gradle
apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
flutter {
source '../..'
}
複製程式碼
這裡制定了 flutter 的gradle,並且制定了 flutter 的source 檔案目錄。
我們可以猜測出來,flutter相關的構建和依賴,都是 flutter 的gradle 檔案裡面幫我們做的。那麼在我們自己建立的原生 module 內部,也用同樣的方式去組織。就可以了。
同時,我們可以根據自己的實際去制定 flutter 的 source 路徑。也通過 repo 將原生的module 和 dart 的lib目錄,分成2個git倉庫。就完美實現了程式碼的隔離。對於原生開發來說,後面的構建打包等持續整合都不會收到 flutter 的影響。
混合工程的架構如下:
混合工程啟動和除錯
在一個 flutter 工程中,我們一般是使用 flutter run
命令啟動一個 flutter 應用。這時候我們就會有關注到:混合工程中,我們進入app會先進入原生頁面,如何再進入 flutter 頁面。那麼我們如何使用熱過載和除錯功能呢。
熱過載
以 Andorid 為例,我們可以先給 app 進行 ./gradlew assembleDebug
打出一個 apk 包。
然後使用
flutter run --use-application-binary {debug apk path}
複製程式碼
命令。會啟動我們的原生 app, 進入特定的 flutter 入口頁面,命令列會自動出現 flutter 的 hot reload。
混合工程除錯
那麼我們如何進行 flutter 工程的除錯呢?我們可以通過給原生的埠和移動裝置的 Observatory
埠進行對映。其實這個方法也同樣適用於我們執行了一個純 flutter 應用,想通過類似 attach 原生程式的方式裡面開始斷點。
命令列啟動app, 出現flutter 的hotreload 後,我們可以看到
An Observatory debugger and profiler on Android SDK built for x86 is available at:
http://127.0.0.1:54946/
複製程式碼
這端。這個地址,我們可以開啟一個關於 dart 的效能和執行情況的展示頁面。
我們記錄下這個埠 xxxx
然後通過 adb logcat | grep Observatory
檢視手機的埠,可以看到如下輸出
我們把最後一個地址輸入到手機的瀏覽器,可以發現手機上也可以開啟這個頁面
我們可以理解成這裡是做了一次埠對映,裝置上的埠記錄為 yyyy
在 Android Studio 中,我們在 run -> Edit Configurations 裡面,新建一個 dart remote debug
, 填寫 xxxx 埠。
如果不成功,可以手動 forward 一下
adb forward tcp:xxxx tcp:yyyy
複製程式碼
然後啟動這個偵錯程式,就可以進行 dart 的斷點除錯了。
原生能力和外掛開發
在 flutter 開發中,我們需要經常使用原生的功能,具體的可以參考 官方文件, native 和 flutter 通過傳遞訊息,來實現互相呼叫。
架構圖如下
檢視原始碼,可以看到 flutter 包括 4 中 Channel 型別。
BasicMessageChannel
是傳送基本的資訊內容的通道MethodChannel
和OptionalMethodChannel
是傳送方法呼叫的通道EventChannel
是傳送事件流stream
的通道。
在 Flutter 的封裝中,官方對純 Flutter 的 library 定義為 Package
, 對呼叫了原生能力的 libraray 定義為 Plugin
。
官方同時也提供了 Plugin
工程的腳手架。通過 flutter create --org {pkgname} --template=plugin xx
建立一個 Plugin
工程。內部包括三端的 library 程式碼,也包括了一個 example
目錄。裡面是一個依賴了此外掛的 flutter 應用工程。具體可以參考外掛文件
在實踐中,我們可以發現 Plugin 的依賴關係如下。
例如我們的 Flutter 應用叫 MyApp
, 裡面依賴了一個 Plugin
叫做 MyPlugin
。那麼,在 Andorid APP 中,庫依關係如下圖
但是如果我們在建立外掛工程的時候,原生部分程式碼,不能依賴到外掛的原生 aar。這樣每次編譯的時候就會在 GeneratedPluginRegistrant
這個類中報錯,依賴關係就變成了下圖
我們會發現紅色虛線部分的依賴在外掛工程中是不存在的。
仔細思考一下會發現,其實我們在 Flutter 應用工程中使用 Plugin
的時候,只是在 pubspec.yaml
中新增了外掛的依賴。原生部分是怎麼依賴到外掛的呢?
通過比較 flutter create xx
(應用工程) 和 flutter create --template=plugin
(外掛工程) ,我們會發現在settings.gradle
中有一些不一樣。應用工程中,有如下一段自動生成的 gradle 程式碼
gradle 會去讀取一個 .flutter-plugins
檔案。從這裡面讀取到外掛的原生工程地址,include 進來並制定了 path。
我們檢視一個 .flutter-plugins
檔案:
path_provider=/Users/chenglei/flutter/.pub-cache/hosted/pub.flutter-io.cn/path_provider-1.1.0/
複製程式碼
我們也可以大致猜測到,flutter的 gradle 指令碼里面會把自己include進來的外掛工程全部依賴一遍。
從這個角度,我們發現外掛工程開發還是有一些規則上的限制的。 從開發的角度看,必須遵循腳手架的規範編寫程式碼。如果依賴其他的外掛,必須自己寫指令碼解決上面的依賴問題。 從維護的角度看,外掛工程仍然需要至少一個android 同學 加一個 iOS 同學進行維護。
所以我們在涉及原生的 Flutter 基礎庫開發中,沒有采用原生工程的方式。而是通過獨立的 fluter package、獨立的android ios module打二進位制包的形式。
flutter基礎設施之路
基於上一小節的結論,我們開發了自己的一套 flutter 基礎設定。我們的基建大致從下面幾個角度出發
- 利用現有能力:基於 Channel 呼叫原生的能力,例如網路、日誌上報。可以收攏 APP 中這些基礎操作
- 質量和穩定性:Flutter 是新技術,我們如何在它上線的時候做到心中有底
- 開發規範:從早期就定下第一版的程式碼結構、技術棧選擇,對於後面的演進益大於弊
利用現有能力
我們封裝了 Channel
,開發了一個 DartBridge
框架。負責原生和 Dart 的互相呼叫。在此之上,我們開發了網路庫、統一跳轉庫等基礎設施
DartBridge
反觀 e網通
APP 在 webview 的通訊,是在訊息到達另一端後,通過統一的路由呼叫格式進行路由呼叫。對於路由提供方來說,只識別路由協議,不關心呼叫端是哪一段。在一定程度上,我們也可以把統一的路由協議理解為“跨平臺”。我們內部協議的格式是如下形式:
scheme://{"domain":"", "action":"", "params":""}
所以在 Flutter 和原生的通訊中,結合實際業務場景,我們沒有使用 MethodChannel
,而是使用了 BasicMessageChannel
, 通過這一個 channel,傳送最基本的路由協議。被呼叫方收到後,呼叫各自的路由庫,返回撥用結果給通道。我們封裝了一套 DartBridge 來進行訊息的傳遞。
通過閱讀原始碼我們可以發現,Channel 的設計非常的完美。它解耦了訊息的編解碼方式,在 Codec
物件中,我們可以進行我們的自定義編碼,例如序列化為 json 物件的 JsonMessageCodec
。
var _dartBridgeChannel = BasicMessageChannel(DART_BRIDGE_CHANNEL,JSONMessageCodec());
複製程式碼
在實際開發中,我們可能想要查詢訊息內容。如果訊息的內容是獲取原生的內容,例如一個學生的作業總數,我們希望在原生提供服務前,不阻塞自己的開發。並且在不修改業務程式碼的情況下獲取到路由的mock資料。所以我們在路由的內部增加了攔截器和mock服務的功能。在sdk初始化的時候,我們可以通過物件配置的方式,配置一些對應 domain、action的mock資料。
整個 DartBridge 的架構如下
基於這個架構模型,我們收到訊息後,通過原生路由(例如 ARouter)方案,去進行相應的跳轉或者服務呼叫。
網路庫 EIO
Flutter 提供了自己的http 包。但是整合到原生app的時候,我們仍然希望網路這個基礎操作的口子可以被統一管理。包括統一的https支援,統一的網路攔截操作,以及可能進行的統一網路監控和調優。所以在Android中,網路庫我們選擇呼叫 OKHttp。
但是考慮到如果有新的業務需求,我們開發了一個全新的flutter app,也希望在不更改框架層的程式碼,就可以直接移植過去,並且脫離原生的請求。
這就意味著網路架構需要把 網路配置
和 網路引擎
解耦開。本著不重複造輪子的原則,我們發現了一個非常優秀的框架:DIO
DIO 留下了一個 HttpClientAdapter
類,進行網路請求的自定義。
我們實現了這個類,在 fetch()
函式中,通過 DartBridge
,對原生的網路請求模組進行呼叫。返回的資料是一個包括:
- nativeBytes List 網路資料的位元組流
- statusCode 網路請求的 http code
- headers Map<String, dynamic> 網路的 response headers
這些資料,通過 Okhttp 請求可以獲取。這裡有一個細節問題。在 OkHttp 中,請求到的 bytes是一個 byte[], 直接給到dart 這邊,被我強轉成了一個List, 因為java 中 byte的範圍是 -126 - 127 ,所以這時候,就出現了亂碼。
通過對比實際的dart dio請求到的相同的位元組流,我發現,byte中的一些資料轉換成int的時候發生了溢位,變成了負數,產生了亂碼。正好是做一次補碼運算,就成了正確的。所以。我在 dart 端,對資料做了一次統一的轉化:
nativeBytes = nativeBytes.map((it) {
if (it < 0) {
return it + 256;
} else {
return it;
}
}).toList();
複製程式碼
關於 utf8 和 byte 具體的編解碼過程,我們不做贅述。感興趣的同學可以參考一下這篇文章
統一路由跳轉
在 DartBridge
框架的基礎上,我們對接原生的路由框架封裝了我們自己的統一跳轉。目前我們的架構還比較簡單,採用了還是多容器的架構,在業務上去規避這點。我們的容器頁面其實就是一個 FlutterActivity
,我們給容器也設定了一個 path,原生在跳轉flutter的時候,其實是跳轉到了這個容器頁。在容器頁中,拿到我們實際的 Flutter path 和 引數。虛擬碼如下:
val extra = intent?.extras
extra?.let {
val path = it.getString("flutterPath") ?: ""
val params = HashMap<String, String>()
extra.keySet().forEach { key ->
extra[key]?.let { value ->
params[key] = value.toString()
}
}
path.isNotEmpty().let {
// 引數通過 bridge 告訴flutter的第一個 widget
// 在flutter頁面內實現真正的跳轉
DartBridge.sendMessage<Boolean>("app", "gotoFlutter",HashMap<String,String>().apply {
put("path", path)
put("params", params)
}, {success->
Log.e("native跳轉flutter成功", success.toString())
}, { code, msg->
Log.e("native跳轉flutter出錯", "code:$code;msg:$msg")
})
}
}
複製程式碼
那麼,業務在原生跳往 Flutter 頁面的時候,我們每次都需要知道容器頁面的path嗎,很明顯是不能這樣的。 所以我們在上面敘述的基礎上,抽象了一個 flutter 子路由表。進行單獨維護。 業務只需要跳往自己的子路由表內的 path,在 SDK內部,會把實際的path 替換成容器的 path,把路由表 path 和跳轉引數整體作為實際的引數。
在 Andorid 中,我提供了一個 pretreatment
函式,在 ARouter
的 PretreatmentService
中呼叫進行處理。返回最終的路由 path 和 引數。
質量和穩定性
線上開關
為了保證新技術的穩定,在 Flutter 基礎 SDK 中,我們提供了一個全域性開關的配置。這個開關目前還是高粒度的,控制在進入 Flutter 頁面的時候是否跳轉容器頁。 在開關處理的初始化中,需要提供 2 個引數
- 是否允許線上開啟 Flutter 頁面
- 在不能開啟 Flutter 頁面的時候,提供一個 Flutter 和 native 頁面的路由對映表。跳轉到對應的原生頁面或者報錯頁。
線上開關可以和 APP 現有的無線配置中心對接。如果線上出現 Flutter 的質量問題。我們可以下發配置來控制頁面跳轉實現降級。
異常收集
在原生開發中,我們會使用例如 bugly
之類的工具檢視線上收集的 crash 異常堆疊。Flutter 我們應該怎麼做呢?在開發階段,我們經常會發現 Flutter 出現一個報錯頁面。
閱讀原始碼,我們可以發現其實這個錯誤的顯示是一個 Widget:
在 ComponentElement
的 performRebuild
函式中有如下呼叫
在呼叫 build 方法 ctach 到異常的時候,會返回顯示一個 ErrorWidget
。進一步檢視會發現,它的 builder 是一個 static 的函式表示式。
(FlutterErrorDetails details) => ErrorWidget(details.exception)
它的引數最終也返回了一個私有的函式表示式 _debugReportException
最終這裡會呼叫 onError 函式,可以發現它也是一個 static 的函式表示式
那麼對於異常捕獲,我們只需要重寫下面 2 個函式就可以進行 build 方法中的檢視報錯
ErrorWidget.builder
ErrorWidget.builder = (details) {
return YourErrorWidget();
};
複製程式碼
FlutterError.onError
FlutterError.onError = (FlutterErrorDetails details) {
// your log report
};
複製程式碼
到這一步,我們進行了檢視的異常捕獲。在 dart 的非同步操作中丟擲的異常又該如何捕獲呢。查詢資料我們得到如下結論:
在 Flutter 中有一個 Zone
的概念,它代表了當前程式碼的非同步操作的一個獨立的環境。Zone 是可以捕獲、攔截或修改一些程式碼行為的
最終,我們的異常收集程式碼如下
void main() {
runMyApp();
}
runMyApp() {
ErrorHandler.flutterErrorInit(); // 設定同步的異常處理需要的內容
runZoned(() => runApp(MyApp()), // 在 zone 中執行 MyApp
zoneSpecification: null,
onError: (Object obj, StackTrace stack) {
// Zone 中的統一異常捕獲
ErrorHandler.reportError(obj, stack);
});
}
複製程式碼
開發規範
在開發初期,我們就內部商議定下了我們的 Flutter 開發規範。重點在程式碼的組織結構和狀態管理庫。 開發結構我們考慮到未來有新增多數 Flutter 程式碼的可能,我們選擇按照業務分模組管理各自的目錄。
.
+-- lib
| +-- main.dart
| +-- README.md
| +-- business
| +-- business1
| +-- module1
| +-- business1.dart
| +-- store
| +-- models
| +-- pages
| +-- widgets
| +-- repositories
| +-- common
| +-- ui
| +-- utils
| +--comlib
| +-- router
| +-- network
複製程式碼
在每個業務中,根據頁面和具體的檢視模組,分為了 page
和 widgets
的概念。store
中,我們會存放相關的狀態管理。repositories
中我們要求業務把各自的邏輯和純非同步操作抽象為獨立的一層。每個業務早期可以維護一個自己的 common, 可以在迭代中不停的抽象自己的 pakcage,並沉澱到最終面向每個人的 comlib。這樣,基本可以保證在迭代中避免大家重複造輪子導致的程式碼冗餘混亂。
在狀態管理的技術選型上,我們調研了包括 Bloc
、'redux和
mobx`。我們的結論是
flutter-redux
的概念和設計非常的優秀,但是適合統一的全域性狀態管理,其實和元件的分割又有很大的矛盾。在開源方案中,我們發現fish-redux
很好的解決了這個問題。Bloc
的大致思路其實和 redux 有很高的相似度。但是功能還是不如 redux 多。mobx
,程式碼簡單,上手快。基本上搞清楚Observables
、Actions
和Reactions
幾個概念就可以愉快的開發。
最終處於上手成本和程式碼複雜度的考慮,我們選擇了 mobx 作為我們的狀態管理元件。
總結
到這裡,我分享了一些 Flutter 的原理和我們的一些實踐。希望能和一些正在研究 Flutter 的同學進行交流和學習。我們的 Flutter 在基礎設施開發的同時,還剝離編寫了一些 升學e網通
APP 上的頁面和一些基礎的 ui 元件庫。在未來我們會嘗試在一些老的頁面中,上線 Flutter 版本。並且研究更好的基礎庫、異常收集平臺、工具鏈優化和單容器相關的內容。