據(jù)模型設計:從序列化到狀態(tài)管理實戰(zhàn))
這段時間把一直維護的“軟件開發(fā)助手App”遷移到了OpenHarmony平臺。選型時沒有直接拿ArkTS重寫而是走了Flutter路線原因很樸素團隊手里已經(jīng)有一套成熟的Flutter代碼庫UI、業(yè)務邏輯、數(shù)據(jù)模型都是現(xiàn)成的遷移到OpenHarmony主要解決平臺適配和底層能力對接的問題從零重寫一遍既不劃算也容易引入新Bug。這篇文章不聊環(huán)境怎么配、跑通Hello World要幾步重點記錄一下我在這個項目里做數(shù)據(jù)模型設計時踩過的坑、想清楚的道理以及可以直接抄走的代碼。先默認你已經(jīng)把Flutter SDK、OpenHarmony SDK和DevEco Studio裝好了。我這邊用的OpenHarmony API版本是10那套Flutter用的是社區(qū)維護的ohos分支。整體體驗下來Dart層幾乎不用改真正需要花心思的是模型層如何對上OpenHarmony的持久化能力以及序列化、狀態(tài)管理這些老生常談但又容易翻車的地方。1. 為什么要用 Flutter 開發(fā) OpenHarmony 應用1.1 一套代碼多端覆蓋的實際收益Flutter進入OpenHarmony生態(tài)這件事社區(qū)已經(jīng)折騰了挺久從最初的demo級支持到現(xiàn)在可以跑完整業(yè)務進步很明顯。對于“軟件開發(fā)助手”這種工具型App它不像電商App那樣有大量復雜的原生交互核心場景是項目管理、依賴檢查、構建日志查看、任務待辦這些頁面全部可以用Flutter自帶組件搞定。同一套Dart代碼在Android、iOS、OpenHarmony上跑差異被收斂到平臺通道和少量條件編譯里這對小團隊來說太關鍵了。數(shù)據(jù)模型層在這個背景下成了“契約層”。UI和狀態(tài)管理依賴模型平臺通道和本地存儲也依賴模型。如果模型設計得不好要么UI層寫著一堆手工拼Map的代碼要么平臺通道里字符串滿天飛時間一長必然失控。所以我這次做遷移第一步不是畫頁面而是把所有數(shù)據(jù)模型重新梳理了一遍。1.2 模型設計為什么是遷移的勝負手跨端項目里模型承擔著幾層職責第一層是內(nèi)存中的狀態(tài)載體跑在Dart虛擬機里供UI和邏輯讀取第二層是序列化邊界模型要能安全地轉(zhuǎn)換為JSON或其他格式落到OpenHarmony的本地存儲里第三層是跨模塊的語言項目信息、任務、依賴之間互相引用模型字段就是它們之間的通信協(xié)議。如果只在單平臺上寫App模型設計隨意一點問題不大反正后續(xù)可以重構。但一旦涉及多端和跨語言能力模型就成了最容易翻車的環(huán)節(jié)。一個字段命名不統(tǒng)一、類型用錯、默認值缺失都會在平臺邊界上被放大。這篇文章想講的就是我如何在一個真實App里把這三個層次做扎實。2. 數(shù)據(jù)模型設計的整體思路2.1 先理業(yè)務再談技術“軟件開發(fā)助手”聽起來很寬泛落到實際功能上主要包含五塊項目管理、任務待辦、依賴管理、構建日志、全局設置。圍繞這五塊我抽象出五個核心實體ProjectInfo項目信息。記錄本地項目路徑、包名、版本號、SDK/API版本、最后構建時間。TaskItem開發(fā)任務。關聯(lián)某個項目包含標題、描述、優(yōu)先級、狀態(tài)、截止時間、標簽。DependencyInfo第三方依賴。關聯(lián)項目記錄依賴名、當前版本、最新版本、許可證類型。BuildLogEntry構建日志。記錄每次構建的時間、狀態(tài)、耗時、產(chǎn)物路徑。UserSettings全局設置。主題模式、默認構建配置路徑、是否自動檢查更新。實體之間不是孤立存在的。一個項目有多條任務、多個依賴、多條構建日志設置項則是全局單例不掛在某個項目下。所以在模型設計時要明確誰擁有誰、誰引用誰這決定了后續(xù)序列化和數(shù)據(jù)庫表設計的方向。2.2 Dart 語言特性對模型設計的影響Dart的很多語言特性天然適合做數(shù)據(jù)模型。我?guī)缀跏菑娭菩缘刈屗心P皖悵M足四個約束不可變所有字段用final構造函數(shù)用const。這樣可以避免對象被意外修改配合狀態(tài)管理時非常好排查問題。顯式序列化每個模型提供fromJson和toJson字段名和類型完全一致不做隱式轉(zhuǎn)換。值語義重寫、hashCode讓兩個內(nèi)容相同的模型對象在邏輯上相等??蓮椭铺峁ヽopyWith方法在狀態(tài)管理中創(chuàng)建修改后的新對象。這四個約定讓模型在團隊里有了統(tǒng)一的“脾氣”寫UI的人拿到模型就知道怎么用寫平臺通道的人拿到模型就知道怎么轉(zhuǎn)JSON。2.3 模型、狀態(tài)與UI的邊界劃分很多新手分不清“數(shù)據(jù)模型”和“頁面狀態(tài)”的區(qū)別容易把一坨東西全塞進模型里。我這次特意整理了邊界Data Model純數(shù)據(jù)結構不含業(yè)務邏輯不含狀態(tài)標識。例如ProjectInfo只負責描述“項目長什么樣”。State狀態(tài)類代表某個頁面或模塊運行時的快照。例如ProjectsState包含加載中、加載成功、加載失敗三種形態(tài)成功態(tài)里持有List 。ViewModel / Controller負責業(yè)務邏輯把數(shù)據(jù)模型轉(zhuǎn)換成UI需要的展示數(shù)據(jù)。這樣切分之后模型類保持輕量、穩(wěn)定狀態(tài)類可以隨業(yè)務快速變化UI層只依賴狀態(tài)類而不直接操作模型。如果你用的是Bloc或Provider這個邊界幾乎不用改就能直接套進去。3. 軟件開發(fā)助手App的數(shù)據(jù)模型落地3.1 項目信息模型與序列化先看最核心的ProjectInfo模型。我直接貼出代碼這是整個App的地基class ProjectInfo { final String id; final String name; final String rootPath; final String packageName; final String version; final String minApiVersion; final String targetApiVersion; final DateTime lastBuildTime; final bool isFavorite; const ProjectInfo({ required this.id, required this.name, required this.rootPath, required this.packageName, required this.version, required this.minApiVersion, required this.targetApiVersion, required this.lastBuildTime, this.isFavorite false, }); factory ProjectInfo.fromJson(MapString, dynamic json) { return ProjectInfo( id: json[id] as String, name: json[name] as String, rootPath: json[rootPath] as String, packageName: json[packageName] as String, version: json[version] as String, minApiVersion: json[minApiVersion] as String, targetApiVersion: json[targetApiVersion] as String, lastBuildTime: DateTime.parse(json[lastBuildTime] as String), isFavorite: json[isFavorite] as bool? ?? false, ); } MapString, dynamic toJson() { return { id: id, name: name, rootPath: rootPath, packageName: packageName, version: version, minApiVersion: minApiVersion, targetApiVersion: targetApiVersion, lastBuildTime: lastBuildTime.toIso8601String(), isFavorite: isFavorite, }; } ProjectInfo copyWith({ String? id, String? name, String? rootPath, String? packageName, String? version, String? minApiVersion, String? targetApiVersion, DateTime? lastBuildTime, bool? isFavorite, }) { return ProjectInfo( id: id ?? this.id, name: name ?? this.name, rootPath: rootPath ?? this.rootPath, packageName: packageName ?? this.packageName, version: version ?? this.version, minApiVersion: minApiVersion ?? this.minApiVersion, targetApiVersion: targetApiVersion ?? this.targetApiVersion, lastBuildTime: lastBuildTime ?? this.lastBuildTime, isFavorite: isFavorite ?? this.isFavorite, ); } override bool operator (Object other) { if (identical(this, other)) return true; return other is ProjectInfo other.id id other.name name other.rootPath rootPath; } override int get hashCode Object.hash(id, name, rootPath); }幾個設計點說明一下時間字段用DateTime而不是毫秒時間戳。原因是JSON里可讀性更好調(diào)試時一眼能看出構建時間是什么時候也方便和Dart的DateTime API直接交互。API版本字段用String而不是int。OpenHarmony的API版本號雖然看起來是整數(shù)但部分子系統(tǒng)有“10”這種標記用String不會在解析時被卡死。isFavorite字段給了默認值false并且在fromJson里用as bool? ?? false兜底。這是針對舊數(shù)據(jù)沒有該字段的情況做的兼容。3.2 任務與依賴模型的關系設計TaskItem和DependencyInfo都關聯(lián)ProjectInfo我用外鍵式關聯(lián)而不是嵌套對象。也就是說TaskItem只保存projectId不直接持有ProjectInfo對象class TaskItem { final String id; final String projectId; final String title; final String description; final int priority; final String status; // todo, doing, done final DateTime? dueDate; final ListString tags; const TaskItem({ required this.id, required this.projectId, required this.title, required this.description, required this.priority, required this.status, this.dueDate, this.tags const [], }); factory TaskItem.fromJson(MapString, dynamic json) { return TaskItem( id: json[id] as String, projectId: json[projectId] as String, title: json[title] as String, description: json[description] as String, priority: json[priority] as int, status: json[status] as String, dueDate: json[dueDate] null ? null : DateTime.parse(json[dueDate] as String), tags: (json[tags] as Listdynamic?) ?.map((e) e as String) .toList() ?? const [], ); } MapString, dynamic toJson() { return { id: id, projectId: projectId, title: title, description: description, priority: priority, status: status, dueDate: dueDate?.toIso8601String(), tags: tags, }; } }這里優(yōu)先選擇用String作為狀態(tài)字段而不是Dart的enum。原因很現(xiàn)實將來要把任務狀態(tài)存儲到OpenHarmony的關系型數(shù)據(jù)庫或Preferences時String可以直接落庫不需要額外的類型轉(zhuǎn)換器。如果你用enum就得在每個邊界寫轉(zhuǎn)換邏輯代碼量成倍增加。同樣tags用List 而不是用逗號拼接的字符串因為序列化框架天然支持數(shù)組也方便UI層直接展示Chip組件。依賴模型的邏輯類似額外增加了latestVersion字段用于做“版本是否過期”的判斷。判斷邏輯不寫在模型里而是寫在業(yè)務層bool isDependencyOutdated(DependencyInfo dep) { return dep.latestVersion ! null dep.latestVersion ! dep.version; }模型保持純數(shù)據(jù)業(yè)務判斷放到外部函數(shù)這樣測試起來非常容易不需要構造一堆mock對象往里塞邏輯。3.3 日志與設置模型BuildLogEntry相對簡單字段固定id、projectId、buildTime、status、duration、outputPath。class BuildLogEntry { final String id; final String projectId; final DateTime buildTime; final String status; // success, failed, canceled final int durationSeconds; final String outputPath; const BuildLogEntry({ required this.id, required this.projectId, required this.buildTime, required this.status, required this.durationSeconds, required this.outputPath, }); factory BuildLogEntry.fromJson(MapString, dynamic json) { return BuildLogEntry( id: json[id] as String, projectId: json[projectId] as String, buildTime: DateTime.parse(json[buildTime] as String), status: json[status] as String, durationSeconds: json[durationSeconds] as int, outputPath: json[outputPath] as String, ); } MapString, dynamic toJson() { return { id: id, projectId: projectId, buildTime: buildTime.toIso8601String(), status: status, durationSeconds: durationSeconds, outputPath: outputPath, }; } }UserSettings的設計思路和上面的實體不太一樣它更接近一個“配置快照”class UserSettings { final ThemeMode themeMode; final String defaultBuildConfigPath; final bool autoCheckUpdate; final int historySize; const UserSettings({ this.themeMode ThemeMode.system, this.defaultBuildConfigPath , this.autoCheckUpdate true, this.historySize 50, }); factory UserSettings.fromJson(MapString, dynamic json) { return UserSettings( themeMode: ThemeMode.values.firstWhere( (e) e.name json[themeMode], orElse: () ThemeMode.system, ), defaultBuildConfigPath: json[defaultBuildConfigPath] as String? ?? , autoCheckUpdate: json[autoCheckUpdate] as bool? ?? true, historySize: json[historySize] as int? ?? 50, ); } MapString, dynamic toJson() { return { themeMode: themeMode.name, defaultBuildConfigPath: defaultBuildConfigPath, autoCheckUpdate: autoCheckUpdate, historySize: historySize, }; } UserSettings copyWith({ ThemeMode? themeMode, String? defaultBuildConfigPath, bool? autoCheckUpdate, int? historySize, }) { return UserSettings( themeMode: themeMode ?? this.themeMode, defaultBuildConfigPath: defaultBuildConfigPath ?? this.defaultBuildConfigPath, autoCheckUpdate: autoCheckUpdate ?? this.autoCheckUpdate, historySize: historySize ?? this.historySize, ); } }themeMode這里用enum是有意為之因為設置面板下拉選擇需要的是固定枚舉不存在未知值的情況。序列化成字符串存到Preferences里解析時用firstWhere加orElse兜底即使配置被外部改壞了也不會崩潰。3.4 模型與狀態(tài)、UI層的綁定實踐模型設計得再好和狀態(tài)管理配合不當照樣摔跟頭。我這次用的是Bloc模式狀態(tài)類圍繞模型做了一層薄封裝sealed class ProjectState {} class ProjectInitial extends ProjectState {} class ProjectLoading extends ProjectState {} class ProjectLoaded extends ProjectState { final ProjectInfo project; final ListTaskItem tasks; final ListBuildLogEntry logs; const ProjectLoaded({ required this.project, required this.tasks, required this.logs, }); } class ProjectError extends ProjectState { final String message; const ProjectError(this.message); }關鍵點是狀態(tài)類持有模型的不可變對象當需要更新時通過copyWith創(chuàng)建新實例而不是修改原對象。UI層監(jiān)聽狀態(tài)變化一旦項目信息被修改Bloc里重新emit一個新的ProjectLoaded整個頁面自動刷新。這種“不可變模型不可變狀態(tài)”的組合在調(diào)試時體驗很好——任何時候都可以確定當前UI對應的是哪個版本的數(shù)據(jù)不會出現(xiàn)數(shù)據(jù)被偷偷改掉然后界面不刷新的詭異問題。4. 數(shù)據(jù)持久化與跨端兼容處理4.1 OpenHarmony 上的持久化選型數(shù)據(jù)模型最終要落地存儲。在OpenHarmony平臺上我試過幾種方案簡單對比一下方案適用場景優(yōu)點缺點Preferences配置項、少量結構化數(shù)據(jù)簡單、同步讀快不適合大量數(shù)據(jù)關系型數(shù)據(jù)庫(RDB)項目、任務、日志等結構化數(shù)據(jù)支持SQL查詢、事務需要建表、寫DAO層JSON文件全量備份、導入導出直觀、可手動編輯并發(fā)寫入需小心對于UserSettings這種全局配置直接用Preferences。我在Flutter端用了一個社區(qū)適配好的shared_preferences_ohos插件Dart層API和標準shared_preferences一致遷移成本幾乎為零。對于項目和任務用的是RDB。但這里有個現(xiàn)實問題Flutter的標準生態(tài)里沒有直接可用的RDB插件能無縫對接OpenHarmony的RDB服務。我的做法是寫一個平臺通道MethodChannel在OpenHarmony原生側封裝RDB的增刪改查Dart側用統(tǒng)一的Repository接口對接。數(shù)據(jù)模型層的fromJson/toJson在這里派上了大用場——原生側返回的是JSON字符串Dart側一行ProjectInfo.fromJson(jsonDecode(raw))就完成了數(shù)據(jù)落地到內(nèi)存的轉(zhuǎn)換。4.2 模型版本遷移與字段兼容任何App都逃不過模型迭代。舊的本地數(shù)據(jù)和新代碼不兼容是遷移期最容易翻車的地方。我的做法是給所有持久化的JSON對象加一個“schemaVersion”字段。以構建日志為例class BuildLogEntry { final String id; final String projectId; final DateTime buildTime; final String status; final int durationSeconds; final String outputPath; final int schemaVersion; ... }在fromJson里做版本分揀factory BuildLogEntry.fromJson(MapString, dynamic json) { final version json[schemaVersion] as int? ?? 1; if (version 2) { return BuildLogEntry.fromJsonV2(json); } return BuildLogEntry.fromJsonV1(json); }V1到V2的遷移可能就是增加了一個字段但通過版本分揀老數(shù)據(jù)不會被新代碼錯誤解析。升級時寫一個遷移函數(shù)遍歷舊數(shù)據(jù)補默認值后寫入新字段整個過程平滑無感。另外一個很實際的坑OpenHarmony上不同設備的API版本兼容性不一致。同一個Preferences鍵在高版本設備上讀出來的值可能在低版本設備上不存在。我在模型層約定所有fromJson解析失敗時不能直接拋異常而是捕獲后返回默認值。寧可數(shù)據(jù)暫時顯示為空也不能讓App啟動就崩潰。這個策略在工具型App里尤其重要用戶多數(shù)是開發(fā)者對“數(shù)據(jù)被重置”有一定容忍度但對“閃退”零容忍。5. 常見問題與排查技巧實錄5.1 序列化報錯與 part 關鍵字的使用我用json_serializable做代碼生成的模型類時最常見的錯誤是part關鍵字用錯。很多人分不清import和partimport package:json_annotation/json_annotation.dart; part project_info.g.dart; JsonSerializable() class ProjectInfo { const ProjectInfo({required this.id}); final String id; factory ProjectInfo.fromJson(MapString, dynamic json) _$ProjectInfoFromJson(json); MapString, dynamic toJson() _$ProjectInfoToJson(this); }part project_info.g.dart的作用是把生成的代碼文件嵌入到當前庫中屬于同一個庫的一部分。它和import的最大區(qū)別是part文件不能有獨立的import聲明它繼承主文件的import作用域。所以生成文件里引用的類必須已經(jīng)在主文件里import進來。如果你在part文件里看到“Undefined class”的錯誤十有八九是主文件漏import了對應包。還有一個坑改完模型類后忘記重新生成。每次往模型里加字段一定要重新跑dart run build_runner build --delete-conflicting-outputs否則生成的fromJson還是舊版本運行時會報字段缺失。5.2 狀態(tài)管理中模型更新的典型翻車現(xiàn)場我剛開始用Bloc時遇到過“修改了任務狀態(tài)但頁面不刷新”的問題。排查下來發(fā)現(xiàn)我在Bloc里犯了一個經(jīng)典錯誤直接修改了傳入的TaskItem對象屬性。但TaskItem的所有字段都是final根本不可能被修改編譯期就會報錯——這就是不可變模型的好處把錯誤提前到編譯期。但如果你用的是可變模型問題就會變成運行期Bug排查起來痛苦得多。所以我的建議是寧可多寫幾十行copyWith代碼也要保證模型不可變。這是Flutter狀態(tài)管理項目里性價比最高的一條紀律。另一個常見的坑是列表更新時錯誤地使用了“先取出舊列表、修改、再塞回”的方式// 錯誤示范 final tasks state.tasks; tasks[0] updatedTask; // state.tasks是final但列表本身的內(nèi)容被改了 emit(state.copyWith(tasks: tasks));這種方式雖然能觸發(fā)狀態(tài)更新但破壞了不可變性原則調(diào)試工具里看到的狀態(tài)時間線是混亂的。正確做法是// 正確示范 final updatedTasks [ for (var i 0; i state.tasks.length; i) if (i 0) updatedTask else state.tasks[i] ]; emit(state.copyWith(tasks: updatedTasks));其實就是用不可變的方式創(chuàng)建新列表雖然多寫幾行但狀態(tài)流永遠是清晰的。5.3 與構建和版本管理相關的幾個坑Flutter的ohos分支構建時有兩次遇到Gradle構建失敗報錯信息里能看到類似“applying flutters main gradle plugin imperatively”的提示。這類問題大多是因為構建腳本里混用了apply語法導致的解決辦法是檢查根目錄build.gradle插件聲明方式保持Flutter官方模板的結構不要在OpenHarmony的工程里手工套用純Android的構建寫法。另外不同OpenHarmony設備對API版本的支持程度不同。我在API 10的設備上測試一切正常但在API 9的機器上調(diào)用某個RDB接口就報錯。排查后發(fā)現(xiàn)是API 10新增的接口在API 9不存在。我的做法是在平臺通道里統(tǒng)一做版本判斷低版本設備降級到文件存儲這樣模型層完全感知不到差異。你要記住一個原則跨端模型只關心“數(shù)據(jù)是什么”不關心“數(shù)據(jù)存在哪”。6. 寫在最后的一點體會數(shù)據(jù)模型設計這件事做的時候不覺得多驚艷但項目越往后越能感覺到它的分量。這次把App遷移到OpenHarmony模型層因為從一開始就堅持了不可變、顯式序列化、版本兜底這三條原則遷移過程比預想順利得多。很多問題在編譯期就被攔下來了真正跑到設備上的Bug少之又少。如果你也在做Flutter跨OpenHarmony的項目我的建議是先花一個下午把所有實體畫出來字段名、類型、關聯(lián)關系寫清楚再動手寫代碼。模型不清晰后面所有層都是空中樓閣。另外不要迷信一鍵代碼生成手寫一遍fromJson/toJson能幫你深刻理解序列化的每個細節(jié)等真出問題時你才有能力快速定位。最后分享個小技巧我習慣給每個模型加一個debugPrint友好的字符串表示重寫toString方法。這樣在打日志排查時直接打印模型對象就能看到完整上下文比打印一堆JSON字符串直觀得多。別看這是個不起眼的細節(jié)調(diào)試效率能提升一大截。