OpenHarmony藝考題庫App:從環(huán)境搭建到學(xué)習(xí)報(bào)告)
今年年初我接到一個(gè)需求要在 OpenHarmony 設(shè)備上做一款藝考真題題庫App刷題、模考、錯(cuò)題本都要有最關(guān)鍵的是要帶學(xué)習(xí)報(bào)告功能。團(tuán)隊(duì)里沒人碰過 Flutter for OpenHarmony 這套組合網(wǎng)上能搜到的資料要么停留在跑通 Hello World要么就是官方文檔里語焉不詳?shù)倪m配說明幾乎沒有一篇能告訴我“題庫 統(tǒng)計(jì)報(bào)告”這種完整產(chǎn)品怎么落地。做完之后我覺得很有必要把全過程寫下來——從環(huán)境搭建、數(shù)據(jù)模型設(shè)計(jì)到學(xué)習(xí)報(bào)告的統(tǒng)計(jì)口徑和圖表實(shí)現(xiàn)再到跨端適配時(shí)踩的一堆坑給后面想用 Flutter 做 OpenHarmony 應(yīng)用的同行留一份能直接抄作業(yè)的參考。這篇文章適合有 Flutter 基礎(chǔ)、想拓到 OpenHarmony 平臺的客戶端開發(fā)者也適合正在評估“題庫類產(chǎn)品跨端方案”的產(chǎn)品和技術(shù)負(fù)責(zé)人。1. 為什么選 Flutter 做 OpenHarmony 題庫需求背后的真實(shí)原因1.1 藝考刷題這個(gè)場景到底特殊在哪藝考和普通升學(xué)考不一樣它不是一個(gè)“科目大盤子”而是覆蓋美術(shù)、音樂、舞蹈、播音編導(dǎo)等多個(gè)大類每個(gè)大類下面還有細(xì)分方向的題庫需求。我做這個(gè)項(xiàng)目時(shí)甲方是某培訓(xùn)機(jī)構(gòu)他們的學(xué)生要刷的理論題主要是文藝常識、美術(shù)史、樂理、藝術(shù)概論、影視編導(dǎo)基礎(chǔ)這幾塊。題型以單選題、多選題、判斷題為主偶爾摻雜一些填空題。這帶來三個(gè)特殊點(diǎn)知識點(diǎn)標(biāo)簽是剛需。學(xué)生刷完題不是只看對錯(cuò)的教務(wù)老師要能看出“這個(gè)班文藝常識普遍掌握得差”還是“樂理里面調(diào)式判斷總是錯(cuò)”。所以題目數(shù)據(jù)模型里必須有 category 和 knowledgePoint 兩個(gè)字段學(xué)習(xí)報(bào)告全靠它們聚合。沒有這兩個(gè)字段后面做報(bào)告就是空中樓閣。碎片化刷題場景多。藝考生的訓(xùn)練時(shí)間被素描、形體、發(fā)聲練習(xí)切得很碎刷題基本發(fā)生在通勤路上、候場間隙、睡前十分鐘離線可用是第一優(yōu)先級在線同步反而是其次。報(bào)告要給機(jī)構(gòu)看。學(xué)習(xí)報(bào)告不只是學(xué)生自己的復(fù)盤工具也是機(jī)構(gòu)續(xù)課和教學(xué)調(diào)整的抓手。這意味著報(bào)告不能只給一個(gè)總正確率得拆到知識點(diǎn)維度最好還有時(shí)間趨勢。1.2 Flutter OpenHarmony 的組合邏輯這個(gè)項(xiàng)目的客戶端預(yù)算是有限的團(tuán)隊(duì)里沒有專職的 ArkTS 原生開發(fā)。而 OpenHarmony 生態(tài)的設(shè)備尤其是機(jī)構(gòu)會統(tǒng)一采購的學(xué)習(xí)終端和平板數(shù)量在明顯增長產(chǎn)品覆蓋范圍把 OpenHarmony 劃進(jìn)來了。Flutter 在這里的價(jià)值是一套 Dart 代碼同時(shí)出 Android 和 OpenHarmony 兩個(gè)安裝包。OpenHarmony 的 Flutter 支持來自開源社區(qū)的維護(hù)分支純 Dart 實(shí)現(xiàn)的 UI 和業(yè)務(wù)邏輯基本都能被這層適配接住前提是別用太多平臺插件。我在技術(shù)選型階段就定了一條原則能直接搜到 OHOS 適配的插件才用搜不到就用純 Dart 方案或者自己寫 MethodChannel絕對不在關(guān)鍵路徑上賭一個(gè)“據(jù)說能用”的插件。這條原則后面幫我們省了非常多排查時(shí)間。2. 環(huán)境搭建從 0 到 1版本選型和第一個(gè) HAP2.1 工具鏈和版本選型Flutter for OpenHarmony 的 SDK 不能用官方 Flutter 發(fā)行版得用 OpenHarmony 社區(qū)維護(hù)的 flutter_flutter 倉庫的 ohos 分支。我落地時(shí)用的是基于 Flutter 3.7 系列的適配版本這套 API 對做題庫App完全夠用。需要注意不要混用官方 Flutter SDK否則 build 時(shí)根本找不到 ohos 平臺。需要配套的 OpenHarmony SDK在 IDE 里下載對應(yīng) API 版本的 SDK。構(gòu)建產(chǎn)物是 HAP 包類似 Android 的 APK。調(diào)試期用 Debug 證書沒問題發(fā)布必須配置簽名這和 Android 的 keystore 邏輯類似。工具鏈清單大致如下組件作用備注flutter_flutterohos分支Dart 側(cè) SDK 與編譯工具通過 git 拉取配置到 PATHOpenHarmony SDK原生側(cè)編譯所需在 IDE 中配置路徑OpenHarmony 配套 IDE管理 ohos 工程、簽名、構(gòu)建 HAP打開項(xiàng)目下的 ohos 目錄使用ohpmOpenHarmony 的包管理器原生側(cè)依賴安裝2.2 工程結(jié)構(gòu)長什么樣Flutter 工程創(chuàng)建之后除了常規(guī)的 android、ios 目錄還會多出一個(gè) ohos 目錄這就是 OpenHarmony 的殼工程。核心結(jié)構(gòu)是項(xiàng)目根/ ├─ lib/ # Dart 業(yè)務(wù)代碼 ├─ ohos/ # OpenHarmony 殼工程 │ ├─ entry/ │ │ └─ src/main/ets/ # ArkTS 原生側(cè)代碼 │ ├─ AppScope/ # 應(yīng)用級配置 │ └─ oh-package.json5 # 原生依賴聲明 ├─ assets/ # 題目 JSON、圖片等資源 └─ pubspec.yaml大多數(shù)時(shí)候我們只需要在 lib/ 下寫 Dartohos/ 目錄里的東西不做大改。只有涉及原生能力調(diào)用時(shí)才需要?jiǎng)?ets 目錄下的文件。2.3 跑通第一個(gè) HAP我這邊跑通工程的完整路徑是這樣把 ohos 分支的 Flutter SDK 下載到本地配好 PATH確認(rèn)flutter --version能輸出版本信息。創(chuàng)建工程后在項(xiàng)目根目錄新建local.properties寫入ohos.sdk.dir你的OpenHarmony SDK路徑。這一步不寫后面flutter devices識別不到 OpenHarmony 設(shè)備。連接設(shè)備或啟動模擬器執(zhí)行flutter devices能看到帶 ohos 標(biāo)識的設(shè)備條目。直接flutter run -d 設(shè)備ID首次構(gòu)建會非常慢因?yàn)橐幾g引擎相關(guān)的 so 文件耐心等。如果要出可安裝的 HAP 包把 ohos 目錄導(dǎo)入配套 IDEIDE 會跑 ohpm 安裝原生依賴同時(shí)幫你處理簽名再觸發(fā)構(gòu)建。2.4 環(huán)境上踩過的幾個(gè)坑flutter doctor 一直報(bào)警。OpenHarmony 平臺的工具鏈不會被完整識別doctor 會反復(fù)提示 Android toolchain 缺失。別慌這說明不了什么只看實(shí)際構(gòu)建能不能過。Debug 能跑、Release 裝不上。Release 包沒配簽名設(shè)備上安裝直接失敗。解決方式是 IDE 里生成證書并配置到工程的 build-profile.json5 里。熱重載是半個(gè)廢的。純 Dart 代碼改動基本能熱更新但動了原生 ArkTS 或者引擎相關(guān)的東西必須整個(gè) HAP 重新構(gòu)建不能指望熱重載救場。3. 題庫和刷題核心數(shù)據(jù)模型、組卷與錯(cuò)題歸集3.1 題目數(shù)據(jù)模型題目數(shù)據(jù)我設(shè)計(jì)成了一份 JSON 文件放進(jìn) assets而不是一開始就塞進(jìn)數(shù)據(jù)庫。原因有兩個(gè)題目是只讀數(shù)據(jù)一套題庫撐死幾千條全量加載進(jìn)內(nèi)存也就幾兆用 JSON 啟動時(shí)加載能繞開 sqflite 這類數(shù)據(jù)庫插件在 OpenHarmony 上適配不穩(wěn)定的問題。單題結(jié)構(gòu)如下{ id: mc_00123, type: 0, category: 美術(shù)史, knowledgePoint: 文藝復(fù)興美術(shù), stem: 下列哪位藝術(shù)家被認(rèn)為是“文藝復(fù)興美術(shù)三杰”之一, options: [達(dá)·芬奇, 魯本斯, 倫勃朗, 委拉斯貴茲], answer: [0], analysis: 文藝復(fù)興美術(shù)三杰指達(dá)·芬奇、米開朗琪羅、拉斐爾。 }type 用 0 單選、1 多選、2 判斷answer 用數(shù)組是為了兼容多選題。knowledgePoint 字段在學(xué)習(xí)報(bào)告階段是聚合維度所以導(dǎo)入時(shí)就必須按一套固定知識點(diǎn)詞表清洗不能隨性亂填。這里的教訓(xùn)是數(shù)據(jù)清洗比寫代碼費(fèi)時(shí)間。我們當(dāng)時(shí)拿到的原始題庫是 Excel里面“文藝復(fù)興”一會兒寫成“文藝復(fù)興時(shí)期美術(shù)”一會兒寫成“文藝復(fù)興藝術(shù)”我寫了段 Python 腳本按映射表統(tǒng)一替換才保證了后面報(bào)告統(tǒng)計(jì)口徑不出岔子。3.2 刷題流程與錯(cuò)題歸集刷題頁的狀態(tài)機(jī)很簡單idle - answering - answered - next。用戶點(diǎn)選項(xiàng)時(shí)記錄選中答案點(diǎn)“下一題”時(shí)把這條答題記錄寫入本地庫同時(shí)更新當(dāng)前會話的統(tǒng)計(jì)緩存。練習(xí)記錄表是整套報(bào)告系統(tǒng)的地基字段是這樣設(shè)計(jì)的CREATE TABLE practice_records ( id INTEGER PRIMARY KEY AUTOINCREMENT, question_id TEXT NOT NULL, category TEXT NOT NULL, knowledge_point TEXT NOT NULL, is_correct INTEGER NOT NULL, selected_answer TEXT NOT NULL, duration_ms INTEGER NOT NULL, created_at INTEGER NOT NULL ); CREATE INDEX idx_records_created ON practice_records(created_at); CREATE INDEX idx_records_kp ON practice_records(knowledge_point);每條記錄必須冗余 category 和 knowledge_point而不是通過 question_id 去題目表關(guān)聯(lián)查。原因很實(shí)際題目 JSON 是內(nèi)存數(shù)據(jù)如果報(bào)告聚合時(shí)要回表關(guān)聯(lián)既要跨存儲又慢。冗余字段雖然在插入時(shí)多寫幾個(gè)字符串但換來的是報(bào)告查詢一次本地庫就全部拿到。錯(cuò)題歸集我用了單獨(dú)一張錯(cuò)題表question_id 做唯一約束答錯(cuò)自動插入答對且已在錯(cuò)題表里的記錄刪掉。這樣“錯(cuò)題清零”功能也順手實(shí)現(xiàn)了。3.3 模擬考試組卷邏輯??疾皇请S機(jī)抽題那么簡單得按考綱比例來。我用的組卷算法是按 category 的權(quán)重分層抽樣再做全局洗牌最后用固定隨機(jī)種子保證同一套卷子在不同設(shè)備上結(jié)果一致。ListQuestion buildPaper(ListQuestion pool, MapString, double blueprint, int seed) { final random Random(seed); final result Question[]; blueprint.forEach((category, weight) { final bucket pool.where((q) q.category category).toList()..shuffle(random); final need (weight * totalCount).round(); result.addAll(bucket.take(need)); }); result.shuffle(random); return result; }交卷邏輯有個(gè)坑未做答的題在評分時(shí)不能漏掉要按錯(cuò)誤處理。模考記錄也會寫入 practice_records但單獨(dú)加一個(gè) source 標(biāo)識字段區(qū)分練習(xí)和??紙?bào)告里這兩個(gè)數(shù)據(jù)要分開看混在一起會嚴(yán)重污染知識點(diǎn)正確率。4. 學(xué)習(xí)報(bào)告實(shí)現(xiàn)從埋點(diǎn)到圖表4.1 數(shù)據(jù)采集一次答題記錄什么學(xué)習(xí)報(bào)告能不能做出來全看埋點(diǎn)定義。我埋的字段就是 3.2 節(jié)那張表question_id、category、knowledge_point、is_correct、duration_ms、created_at。這里要說三個(gè)容易漏的點(diǎn)duration_ms 必須逐題記。有了它才能算“平均每題用時(shí)”“知識點(diǎn)耗時(shí)”這些指標(biāo)對機(jī)構(gòu)判斷學(xué)生熟練度很有價(jià)值。created_at 用毫秒時(shí)間戳而不是存成“2025-04-01 10:00”這種格式化字符串。日期聚合只有在查詢時(shí)才做格式化存時(shí)間戳能避免時(shí)區(qū)問題和格式解析開銷。會話時(shí)長單獨(dú)處理。單題時(shí)長的總和并不等于用戶實(shí)際學(xué)習(xí)時(shí)長因?yàn)橛脩魰趦傻李}之間發(fā)呆。我在刷題會話開始和結(jié)束時(shí)各記一個(gè)時(shí)間點(diǎn)App 切后臺時(shí)用 AppLifecycleState 回調(diào)自動結(jié)算本段這個(gè)口徑更接近真實(shí)學(xué)習(xí)投入。4.2 統(tǒng)計(jì)口徑正確率、學(xué)習(xí)時(shí)長、連續(xù)天數(shù)怎么算統(tǒng)計(jì)口徑是最容易被產(chǎn)品和技術(shù)拉扯的地方我踩過之后強(qiáng)烈建議先寫一份統(tǒng)計(jì)口徑文檔再寫代碼。我最終落地的口徑是這樣的正確率 正確題數(shù) ÷ 總題數(shù)。但??贾形醋鞔鸬念}一律計(jì)錯(cuò)練習(xí)中用戶中途放棄的不計(jì)。知識點(diǎn)掌握度 該知識點(diǎn)答對次數(shù) ÷ 該知識點(diǎn)練習(xí)次數(shù)。練習(xí)題量少于 3 條的知識點(diǎn)不展示避免“練了一題對了就說掌握度 100%”的假象。學(xué)習(xí)時(shí)長 會話時(shí)長累加不是單題時(shí)長累加。連續(xù)學(xué)習(xí)天數(shù) 從今天開始往前掃只要當(dāng)天練習(xí)記錄數(shù)大于 0 就算連續(xù)如果今天還沒有記錄則從昨天開始掃。這樣不會因?yàn)椤敖裉爝€沒開始學(xué)”就粗暴斷掉連續(xù)記錄。聚合查詢用 SQL 做也行用 Dart 內(nèi)存聚合也行。我這邊因?yàn)橐呀?jīng)全量查了 records直接在內(nèi)存里分組更順手MapString, double knowledgeAccuracy(ListPracticeRecord records) { final stat String, Listint{}; for (final r in records) { final s stat.putIfAbsent(r.knowledgePoint, () [0, 0]); s[1]; if (r.isCorrect 1) s[0]; } return stat.map((kp, s) MapEntry(kp, s[0] / s[1])); }4.3 圖表落地fl_chart 折線與雷達(dá)圖學(xué)習(xí)報(bào)告頁我做了四個(gè)可視化塊7日正確率趨勢折線圖、知識點(diǎn)掌握度雷達(dá)圖、薄弱知識點(diǎn) Top5列表、每日刷題量柱狀圖。圖表庫選了 fl_chart它在 OpenHarmony 上的表現(xiàn)讓我意外地放心因?yàn)樗羌?Dart 繪制不依賴原生畫布能力。折線圖用 LineChart雷達(dá)圖用 RadarChartRadarChart( RadarChartData( dataSets: [ RadarDataSet( dataEntries: entries, borderColor: Colors.blue, fillColor: Colors.blue.withOpacity(0.2), ), ], radarShape: RadarShape.polygon, ticksTextStyle: const TextStyle(fontSize: 10), ), )這里有個(gè)真實(shí)的坑要講RadarChart 在部分 OpenHarmony 設(shè)備上對觸摸事件處理有兼容問題如果用戶手指滑過雷達(dá)圖偶爾會閃退。我沒花時(shí)間深究底層直接把整個(gè)報(bào)告的圖表區(qū)域包了一層 IgnorePointer用戶只能看不能點(diǎn)。報(bào)告頁本來就是展示型頁面交互需求為零這個(gè)取舍非常劃算。4.4 報(bào)告緩存與刷新策略報(bào)告頁打開時(shí)如果每次都全量掃 practice_records數(shù)據(jù)量上來后會卡。我加了一張冗余聚合表 daily_statsCREATE TABLE daily_stats ( date TEXT PRIMARY KEY, total_count INTEGER DEFAULT 0, correct_count INTEGER DEFAULT 0, duration_ms INTEGER DEFAULT 0 );每次答題完成就在事務(wù)里更新當(dāng)天這一行。打開報(bào)告頁時(shí)只需要查daily_stats 算今日數(shù)據(jù)和 7 日趨勢practice_records 按 knowledge_point 分組算掌握度。兩次小查詢毫秒級響應(yīng)。如果用戶通過某些后臺手段清了練習(xí)記錄daily_stats 會和 records 不一致。我在設(shè)置頁放了一個(gè)“重建統(tǒng)計(jì)數(shù)據(jù)”入口提供全量重建方法報(bào)給用戶“修復(fù)中”省了很多客訴。5. 跨端實(shí)坑清單OpenHarmony 上插件與原生協(xié)作5.1 插件兼容地圖這個(gè)項(xiàng)目做完我對 Flutter 插件在 OpenHarmony 上的兼容性總結(jié)了一張表給同行做個(gè)參考插件/庫是否純 DartOpenHarmony 可用性我的處理provider是正常直接用fl_chart是正常直接用關(guān)閉觸摸shared_preferences否有社區(qū)適配版版本需匹配找個(gè)固定版本鎖死path_provider否沒有可用的自寫 MethodChannelsqflite否不可用改用 JSON 文件存儲注意插件兼容版本是動態(tài)的今天能用的明天可能因?yàn)?SDK 升級掛掉。我的建議是鎖定 flutter_flutter 分支的 commit鎖定所有依賴的精確版本絕不輕易升級。這個(gè)項(xiàng)目從聯(lián)調(diào)到交付工具鏈一個(gè)沒升就是因?yàn)榉€(wěn)定壓倒一切。5.2 自寫 MethodChannel 對接 ArkTSpath_provider 用不了之后我寫了一個(gè)極簡的 MethodChannel 拿應(yīng)用文件目錄。Dart 側(cè)代碼static const MethodChannel _channel MethodChannel(app/native); FutureString getAppDir() async { return await _channel.invokeMethod(getAppDir); }原生側(cè)是用 ArkTS 實(shí)現(xiàn)的和 Android 上用 Kotlin 寫插件是兩套完全不同的 API。我這邊只是在殼工程的 ArkTS 入口里注冊了 channel 實(shí)現(xiàn)返回文件系統(tǒng)路徑。剛開始不熟悉 ArkTS 的模塊注冊規(guī)則折騰了一天才跑通原因只是我在錯(cuò)誤的生命周期節(jié)點(diǎn)執(zhí)行了注冊。后來我把這一小段原生代碼寫成注釋文檔放在工程里交給團(tuán)隊(duì)其他人就沒再出過問題。方法論上我想多說一句自寫 MethodChannel 的數(shù)量要克制。每多一個(gè) channel就多一個(gè)需要在原生側(cè)驗(yàn)證的邊界。這個(gè)項(xiàng)目把所有原生依賴收斂到了三個(gè) channel應(yīng)用目錄獲取、輕量 KV 存儲、設(shè)備信息。剩下的全靠純 Dart 解決。5.3 包體積和性能調(diào)優(yōu)HAP 發(fā)布包體積一開始很大主要成本在 Flutter 引擎的 so 文件。我做三件事把它壓了下來發(fā)布構(gòu)建加--obfuscate --split-debug-info混淆的同時(shí)剝離符號表體積減小明顯。assets 里只打包當(dāng)前大綱對應(yīng)考點(diǎn)的題目 JSON不把全量題庫歷史版本打進(jìn)去。題目配圖統(tǒng)一壓縮到 WebP單張控制在 100KB 以內(nèi)。性能上最大的體感差異是 Debug 模式明顯比 Android 卡上 Release 包之后流暢度恢復(fù)正常。題庫列表頁我用了 itemExtent 固定行高避免長列表滾動時(shí)的反復(fù)測量開銷。這些在 Android 上可能無感的優(yōu)化在 OpenHarmony 的 Flutter 適配層上就是實(shí)打?qū)嵉牧鲿扯炔罹唷?. 交付之后回頭看這個(gè)項(xiàng)目真正的難點(diǎn)不在代碼6.1 時(shí)間花在了三個(gè)沒寫代碼的地方回頭看整個(gè)交付最花時(shí)間的三件事都不是 Flutter 代碼本身第一是題目 Excel 清洗成標(biāo)準(zhǔn) JSON幾百道題的人工核對第二是學(xué)習(xí)報(bào)告統(tǒng)計(jì)口徑的確認(rèn)產(chǎn)品和教務(wù)來回拉扯了好幾輪第三是插件兼容性驗(yàn)證每個(gè)依賴都要在真機(jī)上試一遍。我自己的感受是Flutter for OpenHarmony 的 UI 層開發(fā)體驗(yàn)已經(jīng)相當(dāng)夠用真正的不確定性全在生態(tài)層。哪個(gè)插件沒適配、哪個(gè)原生 API 換了簽名這些問題不會出現(xiàn)在報(bào)錯(cuò)信息里而是冷不丁在某個(gè)流程上絆你一下。6.2 給后來者的三個(gè)選型標(biāo)準(zhǔn)如果你也想入坑 Flutter for OpenHarmony我建議你選依賴時(shí)用三條標(biāo)準(zhǔn)過濾純 Dart 優(yōu)先能查到官方維護(hù)的 OHOS 適配記錄才考慮原生插件。API 簡單且可替換萬一插件掛了自己寫 MethodChannel 的成本要可控。版本鎖死不要追新。這套組合下新版本帶來的不確定遠(yuǎn)大于收益。6.3 最后一點(diǎn)體會做完這套題庫如果讓我重來一次我會在第一天就把“學(xué)習(xí)報(bào)告口徑文檔”和“依賴兼容矩陣”兩個(gè)東西立起來而不是邊開發(fā)邊補(bǔ)。藝考題庫本身不難難的是它要求你在一個(gè)適配還不成熟的平臺上把數(shù)據(jù)、統(tǒng)計(jì)、可視化完整跑通。但反過來說正因?yàn)槠脚_生態(tài)不成熟先趟過這條路的人后面再遇到同類的項(xiàng)目基本就是降維打擊了。希望這篇分享能幫你少踩幾個(gè)我踩過的坑。