InkNote(墨笺):用 Flutter + Rust 做可靠的本地 Markdown 笔记
项目源码:InkNote 的 AtomGit 仓库
FRB 项目:oh-flutter/flutter_rust_bridge
Rust 社区:开放原子旋武开源社区
本地笔记应用看起来不复杂:左边列表,中间编辑器,右边预览,再加一个“自动保存”。可真正开始写以后,最先暴露的问题往往是数据什么时候落盘。
用户连续输入时,上一轮保存可能还没结束;切换笔记时,当前内容可能仍在防抖计时器里;进程恰好在写元数据时退出,还可能留下半截 JSON。InkNote 把这些问题拆成两层:Flutter 负责编辑状态与保存时机,Rust 负责文件组织、元数据恢复和原子写入,中间通过 flutter_rust_bridge(简称 FRB)连接。
本文从社区和框架说起,结合 InkNote 的实际代码,走完环境准备、功能实现、鸿蒙 PC 运行验证,以及问题反馈和代码贡献的完整流程。
一、认识旋武社区与 FRB
1. 旋武社区:Rust 学习与开源协作的入口
开放原子旋武开源社区是开放原子开源基金会旗下的 Rust 专项社区,围绕 Rust 学习、工具使用和开源项目协作建设国内生态。官网提供 Rust 发行版及镜像、学习资料、在线编码体验、社区项目和技术活动等资源。
对于准备使用 Flutter + Rust 开发应用的同学,可以从社区的学习资料和工具资源入手,了解 Rust 工具链与工程实践;遇到具体项目问题时,再进入对应仓库参与讨论和贡献。社区提供学习与交流入口,项目仓库则承载可以跟踪的 Issue 和 Pull Request。
2. FRB:把 Dart 界面与 Rust 能力连接起来
flutter_rust_bridge 是连接 Flutter/Dart 与 Rust 的跨语言绑定工具。开发者在 Rust 中定义对外 API 和数据结构,通过代码生成器生成桥接代码,Dart 侧便可使用生成的接口调用 Rust,并处理返回数据和错误。
本文使用的 FRB 项目入口是:https://atomgit.com/oh-flutter/flutter_rust_bridge。涉及鸿蒙平台的接入说明、版本变化和框架问题反馈,可从该仓库查阅。
在 InkNote 中,FRB 连接的是笔记 API:初始化、读取、创建、更新、删除和分类管理。Flutter 页面通过 Dart 仓库层调用这些 API,Rust 再完成文件操作。FRB 负责跨语言通信,保存顺序、异常恢复和界面状态仍需要应用自己设计。
二、为什么 InkNote 选择 Flutter + Rust + FRB
InkNote 面向鸿蒙 PC 的本地 Markdown 写作场景,需要同时处理桌面交互和文件可靠性。三者的职责可以这样划分:
| 组成 | 在 InkNote 中的职责 | 选择它的价值 |
|---|---|---|
| Flutter | 笔记列表、编辑器、Markdown 预览、主题和快捷键 | 将界面交互集中在一套 Dart 代码中,便于调整桌面与窄窗口布局 |
| Rust | 文件读写、元数据、分类、校验和恢复 | 将存储规则集中实现,并通过临时目录测试验证文件操作 |
| FRB | 生成 Dart/Rust 绑定,传递参数、结果和错误 | 减少手写跨语言类型转换和调用封装,保持业务 API 清晰 |
具体到这个项目,选择 FRB 有四个直接收益。
第一,跨语言接口可以围绕业务命名。 页面需要的是 createNote、updateNote 和 loadNote,无需自行拼接消息协议。修改 Rust 对外结构后重新生成绑定,Dart 侧的调用变化也更容易通过静态检查发现。
第二,编辑状态与文件状态可以分开管理。 每次按键先更新 Dart 草稿,停止输入后再提交完整快照。这样减少了跨语言调用次数,也让输入响应不必等待每次文件写入完成。
第三,Rust 数据层可以独立验证。 存储测试直接操作临时目录;Flutter 控制器和组件测试可以替换成内存仓库。不同层的问题能在各自边界复现,再用真机验证整条调用链。
第四,存储逻辑具备复用基础。 文件组织、分类规则和恢复算法放在 Rust 中,后续扩展平台时可以复用。各平台仍需单独完成原生库构建、应用目录获取和生命周期接入,FRB 不会自动完成这些工作。
对于只需少量文件读写的小工具,纯 Dart 同样是可选方案。InkNote 采用这套组合,是为了让逐渐增长的存储规则有独立边界;引入 Rust 工具链、原生编译和桥接版本管理,也是这次选型需要承担的成本。
三、环境搭建:先参考已有教程,再对齐项目版本
1. 两篇环境准备文章
基础安装过程可以直接参考下面两篇文章:
- 《2026 年如何上车 Flutter-OH:环境搭建与上手流程》:用于准备 Flutter-OH、DevEco Studio、HarmonyOS SDK,检查工具链并了解工程运行和签名流程。
- 《Rust | VS Code搭建Rust开发环境的超详细图文教程总结(含Rust开发常用插件)》:用于了解 Rust 安装、Cargo、VS Code 和常用扩展。该文主要以 Windows 为例;macOS 开发者应按本机平台准备编译与调试工具。
两篇文章分别解决 Flutter-OH 和 Rust 的入门环境问题。复现 InkNote 时,还要按项目构建指南配置 FRB、Rust OHOS target 和可写的 Native SDK;教程中的示例版本不能直接替代项目版本要求。
2. InkNote 的工具链要求
仓库 README 和 Docs/HARMONYOS_BUILD.md 记录的已验证组合如下:
| 工具或平台 | 仓库记录 |
|---|---|
| Flutter-OH | 3.35.8-ohos-0.0.3 |
| Dart | 3.9.2 |
| FRB Dart/Rust 依赖与 codegen | 2.13.0-beta.6 |
| Rust | stable,安装 aarch64-unknown-linux-ohos target |
| DevEco Studio | 6.0 系列 |
| OpenHarmony SDK | API 22,包含 ETS、Native 和 Toolchains |
| 本文目标设备 | arm64 鸿蒙 PC / 2in1 真机 |
先确认终端中的 flutter 指向 Flutter-OH,再安装对应的 Rust target 和生成器:
flutter --version
flutter doctor -v
rustc --version
cargo --version
rustup target add aarch64-unknown-linux-ohos
cargo install flutter_rust_bridge_codegen --version 2.13.0-beta.6
flutter_rust_bridge_codegen --version
FRB 的 Dart 依赖、Rust 依赖和生成器在本项目中应保持同一版本。只升级其中一处,可能导致生成接口与原生库不匹配。
3. 获取项目并生成绑定
git clone https://atomgit.com/nutpi/InkNote.git
cd InkNote
# 首次克隆后创建本地构建配置;已有签名配置时不要覆盖。
cp ohos/build-profile.example.json5 ohos/build-profile.json5
flutter pub get
flutter_rust_bridge_codegen generate
FRB 配置文件 flutter_rust_bridge.yaml 内容如下:
rust_input: crate::api
rust_root: rust/
dart_output: lib/src/rust
生成器从 rust/src/api/ 对外模块生成绑定,输出包括 lib/src/rust/ 和 rust/src/frb_generated.rs。业务改动应写在 API、控制器、仓库层或 Store 中,再重新生成绑定。
鸿蒙构建还需要可写的 SDK 根目录。按项目构建指南,Native 工具链应能定位到 <SDK 根目录>/22/native/llvm/bin/clang。后续命令中的 /path/to/writable/sdk 必须替换为本机实际路径。
四、功能逻辑与核心代码:从编辑到重新加载
1. 先明确完整功能链路
当前 Flutter 版提供笔记创建、编辑、保存和删除,支持分类管理、搜索、Markdown 实时预览、视图切换、主题和字号设置。本文重点分析最影响用户信任的一条路径:
新建笔记 → 编辑草稿 → 自动保存 → Rust 写盘 → 显示保存状态 → 关闭应用 → 重启恢复。
推荐按下面的顺序阅读源码:
InkNote/
├── lib/main.dart FRB 初始化和应用入口
├── lib/src/app/app_controller.dart 草稿、revision、防抖和切换逻辑
├── lib/src/notes/note_repository.dart Dart 仓库接口与 FRB 实现
├── lib/src/notes/note_editor.dart 编辑与预览界面
├── rust/src/api/notes.rs 对 Flutter 暴露的笔记 API
├── rust/src/api/models.rs 跨语言数据结构
├── rust/src/store.rs 文件写入、索引和恢复
├── lib/src/rust/ 自动生成的 Dart 绑定
├── rust_builder/ 原生库构建与 OHOS 集成
└── ohos/ 鸿蒙入口与 HAP 配置
2. 初始化与更新:跨语言接口只表达业务动作
应用先初始化 RustLib,再由 NativeNoteRepository 使用 path_provider 获取应用支持目录,将其中的 floral-notepaper 子目录传给 Rust。这个目录名沿用旧版本标识,用于保持数据兼容。
Rust API 的初始化和更新逻辑如下,代码来自 rust/src/api/notes.rs:
pub fn initialize(data_dir: String) -> Result<AppSnapshot, String> {
let store = NoteStore::open(PathBuf::from(data_dir)).map_err(|error| error.to_string())?;
crate::store::replace_store(store).map_err(|error| error.to_string())?;
with_store(|store| {
Ok(AppSnapshot {
settings: store.load_settings()?,
notes: store.list_notes()?,
categories: store.list_categories()?,
})
})
.map_err(|error| error.to_string())
}
pub fn update_note(
id: String,
title: String,
content: String,
category: String,
) -> Result<Note, String> {
with_store(|store| store.update_note(&id, title, content, category))
.map_err(|error| error.to_string())
}
初始化一次返回包含设置、笔记摘要和分类的 AppSnapshot,Flutter 控制器可以基于同一份初始化结果更新页面。update_note 则只接收笔记 ID、标题、正文和分类,将操作交给 Store。
这里的错误通过 Result 返回,再由桥接层传递到 Dart。控制器必须捕获失败并保留草稿,不能把“调用结束”直接视为“保存成功”。
3. 自动保存:700ms 防抖、串行执行与 revision 分别解决什么
用户输入时,updateDraft() 更新草稿、递增 _revision、将状态设为 dirty,并重置 700ms 定时器。防抖减少保存次数;它本身不解决异步保存的先后顺序。
完整保存方法还需要处理正在执行的任务。下面是仓库 AppController.saveNow() 的实现:
Future<void> saveNow() async {
_autoSaveTimer?.cancel();
if (selectedNote == null || saveState == SaveState.saved) return;
final activeSave = _saveOperation;
if (activeSave != null) {
await activeSave;
if (saveState == SaveState.dirty) return saveNow();
return;
}
final completer = Completer<void>();
_saveOperation = completer.future;
final noteId = selectedNote!.id;
final revision = _revision;
final title = draftTitle;
final content = draftContent;
final category = draftCategory;
saveState = SaveState.saving;
notifyListeners();
try {
final updated = await _repository.updateNote(
id: noteId,
title: title,
content: content,
category: category,
);
if (selectedNote?.id == noteId) {
selectedNote = updated;
}
await _refreshNotes();
saveState = revision == _revision ? SaveState.saved : SaveState.dirty;
} catch (error) {
saveState = SaveState.failed;
_reportError(error);
} finally {
_saveOperation = null;
completer.complete();
notifyListeners();
}
if (saveState == SaveState.dirty && settings.autoSave) {
_autoSaveTimer = Timer(const Duration(milliseconds: 350), saveNow);
}
}
这段代码有三个关键点。
_saveOperation 串行化保存。 已有写入任务时,后续调用先等待它完成,再判断是否仍有脏草稿需要保存,避免控制器同时发起多轮写入。Rust 的 with_store 也使用全局写锁串行访问 Store。
revision 决定当前草稿能否显示“已保存”。 假设 revision 12 发起保存,等待返回时用户又输入,当前版本变为 13。第 12 版保存成功后,页面仍应保持 dirty。这里比较的是 Dart 在发起请求前捕获的局部变量和当前 _revision,revision 并没有作为参数传给 Rust,也没有由 Rust 回传。
旧保存结束后继续安排新保存。 若仍为 dirty 且自动保存开启,控制器安排 350ms 后补存最新草稿。因此,可靠自动保存依赖“防抖 + 串行执行 + 版本比较 + 后续补存”共同工作,单独增加版本号并不能防止旧写入覆盖新内容。
切换笔记前,selectNote() 会先调用 flushPendingSave():等待已有保存,必要时写入脏草稿或重试失败保存;若仍失败,则停留在当前笔记。对于“等待期间又继续输入”“连续快速切换”等情况,还应通过回归测试核对最终草稿和磁盘内容。
4. 文件落盘:先写临时文件,再替换正式文件
本地文件布局如下:
floral-notepaper/
├── metadata.json
├── settings.json
└── notes/
├── <note-id>.md
└── <category>/<note-id>.md
正文使用 UTF-8 Markdown;标题、分类、字数和时间戳等信息保存在元数据中。write_json 和 write_text 最终调用同一个写入方法:
fn write_bytes(&self, path: &Path, bytes: &[u8]) -> Result<(), StoreError> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let temp_path = path.with_extension("tmp");
let mut file = fs::File::create(&temp_path)?;
file.write_all(bytes)?;
file.sync_all()?;
drop(file);
fs::rename(temp_path, path)?;
Ok(())
}
代码先在目标文件同目录创建 .tmp,写入并调用 sync_all(),关闭文件句柄后执行 rename。在目标平台文件系统支持原子替换的前提下,替换前已有正式文件仍保留上一版,从而降低直接覆盖写导致正式文件只剩半截内容的风险。
这个实现的边界也要说清楚:单文件替换不等于 Markdown 与元数据之间的多文件事务;代码没有同步父目录,正常重启验证也不能证明断电级持久性。对写入中断和断电的要求更高时,需要额外设计事务或日志机制,并在目标设备上进行专项验证。
5. 元数据损坏:保留现场,再从正文恢复
load_metadata() 读取失败时,会先调用 back_up_corrupt_file() 保留原文件,再扫描笔记目录重建元数据,最后写回索引。当前实现中的备份文件名形如 metadata.corrupt-<时间戳>.json。
重建会读取根笔记目录和分类子目录中的 .md 文件:从文件名取得 ID,从目录取得分类,从正文首个非空行推导标题,并使用文件修改时间重建时间字段。因此,正文可以重新出现在列表中,但原来独立填写的标题、创建时间等信息未必能够精确恢复。
扫描器只处理 .md,不会自动把残留 .tmp 当成有效笔记。若要进一步实现临时文件恢复,应先校验内容并保留现有正式文件,避免把不完整快照覆盖到最后一份有效数据上。
6. 仓库接口与测试:分别验证状态和文件
NoteRepository 保留 Dart 业务接口,生产环境通过 NativeNoteRepository 调用 FRB,控制器和 Widget 测试则可以注入内存实现。这样能稳定复现自动保存、写入延迟和保存失败,不必让每个 UI 测试都加载原生库。
仓库中已有的控制器测试覆盖切换前保存草稿、保存失败阻止切换,以及保存期间再次编辑后切换等场景;Rust 测试覆盖笔记读写、删除分类后移动笔记,以及损坏元数据的备份和重建。
发布前可在工程根目录执行:
dart analyze lib test
flutter test
cargo fmt --manifest-path rust/Cargo.toml --check
cargo test --manifest-path rust/Cargo.toml
cargo clippy --manifest-path rust/Cargo.toml --all-targets -- -D warnings
内存仓库测试验证界面状态,Rust 测试验证存储行为,真机重启验证两者连通后的持久化结果。还可以补充“销毁 Store → 用同一目录重新打开 → 比较字段”的回归测试,固定正常重启场景下的数据预期。
五、运行方式及真机效果
1. 构建并安装到鸿蒙 PC
完成第三节的环境准备后,用 DevEco Studio 打开项目的 ohos/,在 File > Project Structure > Signing Configs 中配置当前开发者和目标设备的签名。Profile 需要匹配包名 com.floralnotepaper.app、开发者证书和目标 PC 的 UDID。
项目虽然已经更名为 InkNote,但保留旧包名和原生库名以维持兼容,不要仅为了统一显示名称而随意修改这些标识。
替换 SDK 路径后,构建签名 HAP:
OHOS_BASE_SDK_HOME=/path/to/writable/sdk \
flutter build hap --release --target-platform ohos-arm64
按项目构建指南,签名产物位于 build/ohos/hap/entry-default-signed.hap。若当前只验证编译,可添加 --no-codesign 生成未签名包;真机安装应使用匹配设备的签名包。
确认 hdc 已加入 PATH,将下面的 <device-id> 替换为 hdc list targets 输出的实际设备标识:
hdc list targets
hdc -t <device-id> install -r build/ohos/hap/entry-default-signed.hap
hdc -t <device-id> shell aa start \
-a EntryAbility \
-b com.floralnotepaper.app
签名配置完成后,也可以直接调试运行:
OHOS_BASE_SDK_HOME=/path/to/writable/sdk \
flutter run -d <device-id>
如需在 macOS 辅助调试,可在该平台依赖与桌面构建环境就绪后执行 flutter run -d macos;鸿蒙 PC 的最终结果仍应在目标设备上验证。更完整的说明见项目仓库中的 Docs/HARMONYOS_BUILD.md。
2. 当前 Flutter 版效果图

图中展示 InkNote 在 HarmonyOS PC 真机上的运行状态:新建 FRB Storage Acceptance,输入正文,等底部显示“已保存”,关闭应用窗口,再从设备端重新启动。原有实操记录显示,重启后标题、113 字正文和分栏预览仍然存在。
当前效果图使用 Flutter 版应用窗口。仓库 Docs/images 中的早期 Tauri 截图和 GIF 属于迁移历史,不能用来证明当前 Flutter + FRB 的运行效果。
3. 手工验证完整闭环
按下面步骤复现保存与恢复:
- 新建笔记,确认编辑器可以输入标题和正文。
- 输入 Markdown,检查预览内容与编辑区对应。
- 停止输入,等待 700ms 防抖触发保存,直到状态显示“已保存”;磁盘写入耗时需另外计算。
- 修改分类,确认保存后列表与分类筛选同步更新。
- 关闭应用窗口,使用同一包名再次启动,核对标题、正文、分类和字数。
- 再次编辑并保存,确认恢复后的笔记仍可正常更新。
- 在测试数据目录中模拟写入失败,核对草稿保留与切换阻止;破坏元数据后,核对备份文件及重建列表。
原有实操记录的结果范围如下,后两项需要在交付环境中另行验证:
| 验收项 | 原有记录 | 证据或验证方式 |
|---|---|---|
| 鸿蒙应用启动 | 通过 | 真机按包名启动 |
| 新建与正文编辑 | 通过 | 编辑区与预览区出现对应正文 |
| 自动保存 | 通过 | 底部显示“已保存” |
| 关闭窗口后重新打开 | 通过 | 标题、113 字正文和预览恢复 |
| 损坏元数据恢复 | 交付环境需复测 | Rust 测试与测试目录中的人工验证 |
| 断电级持久性 | 未证明 | 需要独立的断电与写入中断测试 |
六、FAQ:从问题定位到 Issue 与 PR
1. 遇到框架问题,如何提 Issue?
先确定问题出现在哪一层。InkNote 的草稿状态、分类逻辑或元数据恢复问题,优先进入 InkNote 仓库;若最小示例中也能复现 FRB 生成失败、类型映射错误或桥接调用异常,则进入 FRB 仓库 的 Issues 页面反馈。
提交前先搜索相同关键词、报错和版本,查看是否已有解决方案。若仓库提供 Issue 模板,按模板填写;至少应包含以下信息:
标题:[OHOS][具体版本] 操作或触发条件 + 实际错误
环境:
- 主机系统与 CPU 架构:
- Flutter-OH / Dart 版本:
- Rust / Cargo 版本:
- FRB Dart、Rust 依赖与 codegen 版本:
- DevEco Studio / SDK API:
- 目标设备型号与系统版本:
- 应用仓库分支或 commit:
复现步骤:
1. 获取哪个最小示例,执行什么命令
2. 修改了哪些配置或 API
3. 触发哪个操作后出现问题
预期行为:
实际行为:
完整错误日志和堆栈:
最小复现仓库或补丁:
已尝试的排查步骤及结果:
尽量把示例缩减到一个 Rust API 和一个 Dart 调用,并说明相同代码在其他平台是否可以运行。日志使用文本保留关键上下文,上传前去除签名口令、令牌和私人笔记内容。这样维护者才能独立复现,而无需先搭建完整笔记应用。
2. 问题定位并完成修复后,如何提 PR?
如果修复来自自己的代码改动,可以在本地修复和验证后提交 Pull Request。通常不需要先把 Issue 关闭;让 PR 关联 Issue,待评审合并并确认结果后再关闭,更便于跟踪。
具体流程如下:
- 在对应 AtomGit 仓库中确认贡献指南及目标分支,必要时先在 Issue 中说明修复思路。
- Fork 仓库并克隆个人 Fork,从维护者要求的目标分支创建修复分支,例如
fix/ohos-native-library-loading。 - 修改导致问题的源代码,补充能复现缺陷的回归测试。涉及 FRB API 或生成器变化时,按仓库约定重新生成相关文件。
- 运行该仓库要求的检查,并在原来报错的平台验证修复。应用修复可使用本文第四节的检查命令;FRB 框架修复应以 FRB 仓库自身的贡献指南和 CI 要求为准。
- 将提交推送到个人 Fork,在 AtomGit 的 Pull Requests 页面选择个人分支作为源分支、原仓库要求的分支作为目标。
- 在 PR 描述中写清问题触发条件、根因、修改后的行为、关联 Issue、执行的测试及平台结果,按评审意见继续修改。
- 合并后记录包含修复的提交或版本,在 InkNote 中更新对应依赖并重新生成、构建和回归验证,最后更新 Issue 状态。
PR 描述可以采用下面的简短结构,示例中的编号应替换为真实 Issue:
问题:在什么环境、执行什么操作时出现什么错误。
原因:错误发生在哪个环节。
修改:修复后有什么可观察的行为变化。
验证:测试命令、目标设备、修复前后的结果。
关联:Issue #<实际编号或链接>。
如果问题只是 SDK 路径配置错误,按说明修复环境并反馈结果即可;若维护者已经合并修复,则升级并验证即可。只有新增代码、测试或文档改动时,才需要提交相应 PR。
3. 其他常见问题与解决方案
| 问题或现象 | 优先检查 | 处理方式 |
|---|---|---|
flutter doctor 没有正确识别鸿蒙工具链 | 当前 Flutter 路径、SDK 配置 | 按 Flutter-OH 环境文章检查 PATH 与 ohos-sdk,确保执行的是 Flutter-OH |
| FRB 生成后 Dart 接口或原生调用不匹配 | 三处 FRB 版本是否一致 | 对齐 Dart、Rust 和 codegen;本项目使用 2.13.0-beta.6,随后重新生成并构建原生库 |
failed to find tool "llvm/bin/clang" | Native SDK 绝对路径 | 检查可写 SDK 中 22/native/llvm/bin/clang 是否存在,以及 Cargokit 使用的 SDK 路径 |
构建尝试 x86_64-unknown-linux-ohos | OHOS ABI 配置与旧缓存 | 按当前 arm64 目标检查 abiFilters,必要时 flutter clean 后重新构建 |
安装报 fail to verify pkcs7 file | Profile、包名、证书和 UDID | 用 DevEco Studio 为当前包名和真机重新配置匹配的调试签名 |
| 启动时报原生库加载失败 | 架构、库名及 HAP 中的动态库 | 检查 arm64 包内的 libfloral_notepaper_native.so 及其依赖,修复构建集成后重新安装 |
| 保存中继续输入,旧请求返回后状态不正确 | _saveOperation、revision 与补存分支 | 用延迟仓库复现,检查串行等待和补存,确认最终文件包含最新输入 |
| 保存失败后无法切换笔记 | 数据目录权限、空间及写入日志 | 保留草稿,修复写入原因后重试;阻止切换用于避免静默丢稿 |
metadata.json 损坏,笔记列表异常 | 正文目录及损坏备份 | 在备份基础上验证重建结果,核对正文、推导标题和分类,不直接清空笔记目录 |
| 重启后像是进入了新工作区 | 包名、应用支持目录、是否卸载重装 | 检查是否仍使用同一应用身份和数据路径;覆盖安装验证期间避免随意修改兼容标识 |
七、后续扩展与项目入口
InkNote 当前已经将 Markdown 编辑、预览、分类管理与本地保存串联起来。后续扩展可以继续围绕数据边界推进:加强退出期间的草稿保护,补充更严格的写入中断测试,并为数据格式升级设计可回退的迁移流程。
普通 Markdown 文件提供了开放的数据基础,但当前 Flutter 版的导入导出等旧 Tauri 功能尚未迁移完成。介绍功能时,应以当前 Flutter 实现和目标设备验证结果为准。
想进一步阅读或参与项目,可以从以下入口开始:
更多推荐


所有评论(0)