# iOS / macOS 开发者指南 > 本项目同时支持 iOS、macOS、Android、鸿蒙(HarmonyOS) 四端。 > 鸿蒙端使用定制 Flutter SDK(`flutter-ohos`),在 `TargetPlatform` 枚举中新增了 `TargetPlatform.ohos`, > 并对大量三方库做了本地化适配。iOS/macOS 开发者需了解以下关键事项,避免踩坑。 --- ## 文档更新日志 | 日期 | 版本 | 变更内容 | |---|---|---| | 2026-06-07 | v10 | 修正 §2.3 dependency_overrides 行数(4→5行/40+→46行);修正 §2.6 补丁引用(§2.8→§2.9);简化 §2.8.1 pro_image_editor 过时回退建议;删除 §5.4 pro_image_editor 本地包条目和 bitsdojo_window 废弃条目;简化 §3.3 pubspec.yaml 处理策略(git stash → 双模板脚本生成);更新 §3.2/§3.5/§6 与双模板机制对齐 | | 2026-06-06 | v9 | 清理未使用依赖:移除 animations、animate_do、value_layout_builder、flutter_advanced_canvas_editor、flutter_blue_plus、http_cache_file_store、dartx、vector_math;删除差异对照表中 flutter_nfc_kit 过时条目 | | 2026-06-06 | v8 | 新增 `app_tracking_transparency` 差异对照条目;新增 `nearby_connections` 鸿蒙端本地stub包说明;新增 §2.10 nearby_connections鸿蒙适配说明 | | 2026-06-02 | v7 | **重大变更**:pubspec.yaml 拆分为双模板(pubspec.ohos.yaml + pubspec.macos.yaml),pubspec.yaml 不再提交到 Git;新增三方库变更通知机制;新增 setup_pubspec.ps1 脚本 | | 2026-06-02 | v6 | 鸿蒙端 pubspec.yaml 同步 bitsdojo_window → window_manager 迁移;更新 file_picker 本地包版本注释(v8.3.7→v11.0.0-ohos.1);更新 speech_to_text(^7.0.0→^7.4.0)、live_activities(^2.0.0→^2.4.9) 远程版本号;补充 dependency_overrides 中 bitsdojo_window_windows 移除说明 | | 2026-06-01 | v5 | 新增 §2.6 pub cache 补丁说明;标记 bitsdojo_window 迁移完成;file_picker 升级到 12.x | | 2026-05-30 | v4 | 初版完整指南 | --- ## 一、环境准备与项目拉取 ### 1.1 Flutter SDK 选择 | 平台 | 推荐SDK | 说明 | |---|---|---| | iOS / macOS | **官方 Flutter SDK** | 标准 stable/beta 渠道即可 | | 鸿蒙 | flutter-ohos 定制SDK | 包含 `TargetPlatform.ohos` 等扩展,仅在鸿蒙开发机上使用 | | Android | 官方 Flutter SDK | 同 iOS | > ⚠️ **iOS/macOS 开发使用官方 Flutter SDK 即可**,无需安装鸿蒙定制SDK。 > 不要用鸿蒙SDK编译iOS/macOS,反之亦然,SDK不兼容会导致编译错误。 ### 1.2 克隆仓库 ```bash # 克隆项目仓库 git clone <仓库URL> xianyan cd xianyan # 查看当前分支 git branch -a # 切换到开发分支(如需) git checkout feature/xxx # 或直接在 main 分支开发 git checkout main ``` ### 1.3 生成 pubspec.yaml 并安装依赖 > ⚠️ **`pubspec.yaml` 不再提交到 Git**。项目使用双模板机制: > - `pubspec.ohos.yaml` — 鸿蒙端模板(使用本地 packages/ 目录) > - `pubspec.macos.yaml` — MacBook Pro 端模板(使用远程版本号) > - `pubspec.yaml` — 由脚本自动生成,已加入 `.gitignore` ```bash # 1. 确认使用官方 Flutter SDK flutter --version # 应显示官方版本,非 flutter-ohos # 2. 运行脚本生成 pubspec.yaml(MacBook Pro 端) .\tools\setup_pubspec.ps1 -Platform macos # 或自动检测平台 .\tools\setup_pubspec.ps1 # 3. 获取依赖 flutter pub get # 4. iOS 编译验证 flutter build ios --no-codesign # 5. macOS 编译验证 flutter build macos ``` 鸿蒙端开发者: ```bash # 1. 确认使用 flutter-ohos SDK flutter --version # 应显示 ohos 版本 # 2. 运行脚本生成 pubspec.yaml(鸿蒙端) .\tools\setup_pubspec.ps1 -Platform ohos # 3. 获取依赖 flutter pub get ``` --- ## 二、pubspec.yaml 双模板机制 ### 2.1 架构概述 > **`pubspec.yaml` 不再提交到 Git**,两端各自维护独立的模板文件。 ``` 项目根目录/ ├── pubspec.ohos.yaml ← 鸿蒙端模板(tracked,使用本地 packages/ 目录) ├── pubspec.macos.yaml ← MacBook Pro 端模板(tracked,使用远程版本号) ├── pubspec.yaml ← 本地生成文件(.gitignore,不提交) └── tools/ └── setup_pubspec.ps1 ← 自动生成脚本 ``` **为什么这样做?** 之前鸿蒙端和 MacBook Pro 端共用一个 `pubspec.yaml`,MacBook Pro 端每次 `git pull` 后需要手动替换 82 行本地包引用,容易出错且经常互相覆盖。 现在: - 鸿蒙端模板 `pubspec.ohos.yaml` 包含所有 `path: packages/` 引用 - MacBook Pro 端模板 `pubspec.macos.yaml` 使用远程版本号 - `pubspec.yaml` 由脚本自动生成,两边都不提交 ### 2.2 MacBook Pro 端的核心原则 > **MacBook Pro 端不需要 `packages/` 目录,直接使用远程三方库即可。** `pubspec.macos.yaml` 已将所有本地包引用替换为远程版本号,无需手动修改。 **为什么不能直接用鸿蒙端的本地包?** 本地包中包含 `TargetPlatform.ohos` 引用(鸿蒙SDK新增的枚举值),官方 SDK 没有此值,会导致编译报错: ``` Error: The getter 'ohos' isn't defined for the class 'TargetPlatform' ``` 受影响的本地包有 5 个: | 包名 | 引用次数 | 引用类型 | |---|---|---| | flex_color_picker | 2 | `case TargetPlatform.ohos:` (switch) | | flutter_quill | 2 | `case TargetPlatform.ohos:` (switch) | | flutter_local_notifications | 8 | `== TargetPlatform.ohos` (if比较) | | mobile_scanner | 3 | `== / != TargetPlatform.ohos` (if比较) | | audioplayers | 1 | `!= TargetPlatform.ohos` (if比较) | **解决方案**:MacBook Pro 端使用远程版本,远程版本不含 `TargetPlatform.ohos`,编译正常。 ### 2.3 双模板差异对照表 | 区域 | pubspec.ohos.yaml(鸿蒙端) | pubspec.macos.yaml(MacBook Pro端) | |---|---|---| | shared_preferences | `path: packages/shared_preferences` | `^2.5.5` | | flutter_secure_storage | `path: packages/flutter_secure_storage` | `^10.2.0` | | hive_flutter | `path: packages/hive_flutter` | `^1.1.0` | | path_provider | `path: packages/path_provider` | `^2.1.5` | | package_info_plus | `path: packages/package_info_plus` | `^10.1.0` | | connectivity_plus | `path: packages/connectivity_plus` | `^7.1.1` | | device_info_plus | `path: packages/device_info_plus` | `^13.1.0` | | permission_handler | `path: packages/permission_handler` | `^12.0.1` | | app_tracking_transparency | `^2.0.6` | `^2.0.6` | | flutter_local_notifications | `path: packages/flutter_local_notifications` | `^21.0.0` | | url_launcher | `path: packages/url_launcher` | `^6.3.2` | | app_links | `path: packages/app_links` | `^7.0.0` | | home_widget | `git: gitcode.com/...` | `^0.9.1` | | file_picker | `path: packages/file_picker` | `^12.0.0-beta.5` | | image_picker | `path: packages/image_picker` | `^1.2.2` | | share_plus | `path: packages/share_plus` | `^13.1.0` | | gal | `path: packages/gal` | `^2.3.0` | | flutter_quill | `path: packages/flutter_quill` | `^11.5.0` | | flex_color_picker | `path: packages/flex_color_picker` | `^3.8.0` | | flutter_image_compress | `path: packages/flutter_image_compress` | `^2.4.0` | | wakelock_plus | `path: packages/wakelock_plus` | `^1.4.0` | | audioplayers | `path: packages/audioplayers` | `^6.5.0` | | record | `path: packages/record` | `^6.0.0` | | video_compress | `path: packages/video_compress` | `^3.1.2` | | video_player | `path: packages/video_player` | `^2.10.0` | | local_auth | `path: packages/local_auth` | `^3.0.1` | | sensors_plus | `path: packages/sensors_plus` | `^6.1.0` | | battery_plus | `path: packages/battery_plus` | `^7.0.0` | | network_info_plus | `path: packages/network_info_plus` | `^8.1.0` | | flutter_webrtc | `path: packages/flutter_webrtc` | `^1.4.0` | | mobile_scanner | `path: packages/mobile_scanner` | `^7.1.4` | | wifi_iot | `path: packages/wifi_iot` | `^0.3.19` | | nearby_service | `path: packages/nearby_service` | `^0.2.1` | | nearby_connections | `path: packages/nearby_connections` (stub) | `^4.1.1` | | sqflite | `path: packages/sqflite` | `^2.4.1` | | workmanager | `path: packages/workmanager` | `^0.9.0` | | flutter_tts | `path: packages/flutter_tts` | `^4.2.0` | | speech_to_text | `path: packages/speech_to_text` | `^7.4.0` | | live_activities | `path: packages/live_activities` | `^2.4.9` | | dependency_overrides | 46 行(含本地包覆盖 + ohos 子包) | 5 行(仅版本号覆盖 + win32 + quill_native_bridge_windows) | ### 2.4 ⚠️ 新增三方库变更流程(必读) > **铁律:新增三方库时,必须同时更新两个模板 + 本文档。** ``` 新增三方库流程: 1. 在 pubspec.ohos.yaml 添加依赖(鸿蒙端优先) 2. 在 pubspec.macos.yaml 添加对应远程版本 3. 在本文档 §2.3 差异对照表添加一行 4. 在本文档顶部更新日志记录变更 5. 在 CHANGELOG.md 记录变更 6. git push 后通知另一端开发者 7. 另一端开发者: git pull → 运行 setup_pubspec.ps1 → flutter pub get ``` **鸿蒙端新增本地化包时额外步骤:** - 将包放入 `packages/` 目录 - 在 `pubspec.ohos.yaml` 的 `dependency_overrides` 中添加覆盖 - 在 `pubspec.macos.yaml` 中使用远程版本号 - 更新本文档 §2.3 差异对照表 **升级三方库版本时:** - 在对应模板中更新版本号 - 如果是鸿蒙本地包升级,同步更新 `pubspec.ohos.yaml` 中的版本注释 - 更新本文档 §2.3 差异对照表中的版本号 ### 2.5 setup_pubspec.ps1 脚本说明 ```powershell # 鸿蒙端 .\tools\setup_pubspec.ps1 -Platform ohos # MacBook Pro 端 .\tools\setup_pubspec.ps1 -Platform macos # 自动检测(根据 flutter --version 或 packages/ 目录是否存在) .\tools\setup_pubspec.ps1 ``` 脚本功能: - 将对应模板复制为 `pubspec.yaml` - 自动备份现有 `pubspec.yaml` → `pubspec.yaml.bak` - 验证模板内容(鸿蒙端检查 `path: packages/`,MacBook Pro 端检查无本地包引用) - 输出后续操作提示 ### 2.6 MacBook Pro 端完整操作流程 ```bash # 1. 克隆仓库 git clone <仓库URL> xianyan cd xianyan # 2. 生成 pubspec.yaml .\tools\setup_pubspec.ps1 -Platform macos # 3. 获取依赖 flutter pub get # 4. 应用 pub cache 补丁(见 §2.9) bash scripts/patch_pub_cache.sh # 5. 编译验证 flutter build ios --no-codesign flutter build macos ``` ### 2.7 鸿蒙端完整操作流程 ```bash # 1. 克隆仓库 git clone <仓库URL> xianyan cd xianyan # 2. 确保 packages/ 目录已准备好(从 zip 解压或 git submodule) # 3. 生成 pubspec.yaml .\tools\setup_pubspec.ps1 -Platform ohos # 4. 获取依赖 flutter pub get ``` ### 2.8 特殊包说明 #### 2.8.1 pro_image_editor(已迁移至远程版本) `pro_image_editor` 已从本地包迁移为远程版本 `^12.4.4`,两端均使用远程版本: ```yaml # 两端均使用远程版本 pro_image_editor: ^12.4.4 ``` #### 2.8.2 file_picker(API 变更 + win32 6.x 兼容) `file_picker` 已从 `^11.0.0` 升级为 `^12.0.0-beta.5`,原因: 1. **API 变更**(11.x → 12.x 通用): ```dart // ❌ 旧版 API(file_picker ^8.x) final result = await FilePicker.platform.pickFiles(); // ✅ 新版 API(file_picker ^11.x / ^12.x) final result = await FilePicker.pickFiles(); ``` 2. **win32 6.x 兼容**(12.x 专属): - `file_picker ^11.0.0` 依赖 `win32: ^5.9.0`,与 `dependency_overrides` 中的 `win32: ^6.0.1` 冲突 - `file_picker ^12.0.0-beta.5` 兼容 `win32 ^6.0.1`,解决 macOS 构建失败 - ⚠️ 12.x 为 beta 版本,如遇问题可回退到 11.x(需同时降级 win32 override,见§2.6) 项目代码已更新为新版 API。鸿蒙端本地包版本已升级为 `11.0.0-ohos.1`(基于 file_picker 11.x 适配), API 已与远程版本对齐,无需额外处理。 #### 2.8.3 flutter_secure_storage(版本差异说明) MacBook Pro 端使用远程版本 `^10.2.0`,其 Windows 平台实现兼容 `win32 ^6.0.1`, 解决了之前版本与 `win32 6.x` 的编译冲突。 > **注意**:鸿蒙端本地包版本为 `9.2.4-ohos.1`(基于 9.x 适配), > 与 MacBook Pro 端远程版本 `10.2.0` 存在主版本号差异。 > 两端 API 兼容,`lib/` 代码无需特殊处理。 #### 2.8.4 receive_sharing_intent(gitcode 引用) `receive_sharing_intent` 在 pub.dev 上的版本与鸿蒙端不兼容,MacBook Pro 端需使用 gitcode 引用: ```yaml # MacBook Pro 端使用 gitcode 引用 receive_sharing_intent: git: url: "https://gitcode.com/openharmony-sig/fluttertpc_receive_sharing_intent.git" ref: "br_v1.8.1_ohos" ``` > **注意**:gitcode 版本的 `SharedMediaFile` 构造函数可能包含 `ohosPath` 等鸿蒙特有参数, > 官方 SDK 不存在这些参数。项目代码中已通过 `pu.isOhos` 条件分支隔离。 #### 2.8.5 flutter_vibrate(gitcode 引用) `flutter_vibrate` 在 pub.dev 上的版本不支持鸿蒙,MacBook Pro 端需使用 gitcode 引用: ```yaml # MacBook Pro 端使用 gitcode 引用 flutter_vibrate: git: url: https://gitcode.com/openharmony-sig/fluttertpc_flutter_vibrate.git ``` #### 2.8.6 home_widget(pub.dev 远程版本) `home_widget` 的 gitcode 版本依赖 `path_provider` 的 git 版本,会与远程 `path_provider` 冲突。 MacBook Pro 端使用 pub.dev 版本 `^0.9.1`,鸿蒙端的 `ohosName` 参数通过 `pu.isOhos` + `dynamic` 调用隔离: ```yaml # MacBook Pro 端使用 pub.dev 远程版本 home_widget: ^0.9.1 ``` #### 2.8.7 nearby_connections(鸿蒙端本地stub包) `nearby_connections` 仅支持 Android/iOS 平台(Google Nearby Connections API),不支持鸿蒙。 鸿蒙端使用本地 stub 包 `packages/nearby_connections/`,提供与 4.x API 一致的类型定义和方法签名, 但所有方法调用抛出 `UnsupportedError`。代码中通过 `isP2pSupported` 守卫,鸿蒙端不会实际调用 P2P 方法。 ```yaml # 鸿蒙端使用本地 stub 包 nearby_connections: path: packages/nearby_connections # MacBook Pro 端使用远程版本 nearby_connections: ^4.1.1 ``` > **注意**:鸿蒙端 stub 包的 Dart API 与远程版本完全一致(枚举、类型、方法签名), > 编译不会报错。运行时由 `NearbyServiceAdapter.isP2pSupported` 守卫, > 鸿蒙端 P2P 功能不可用,仅 `nearby_service` 原生引擎可用。 #### 2.8.8 app_tracking_transparency(两端均使用远程版本) `app_tracking_transparency` 是 iOS 专属权限库(App Tracking Transparency), 两端均使用远程版本 `^2.0.6`,无需本地适配。代码中通过 `Platform.isIOS` 条件守卫, 非 iOS 平台直接返回授权成功,不影响鸿蒙/Android/macOS 编译。 ```yaml # 两端配置相同 app_tracking_transparency: ^2.0.6 ``` ### 2.9 ⚠️ pub cache 补丁(MacBook Pro 端必读) > **关键问题**:`dependency_overrides` 中 `win32: ^6.0.1` 导致部分依赖 `win32 ^5.x` 的三方包编译失败。 > 这些三方包的 Windows 平台代码在 macOS 构建时也会被编译(Dart 编译器不区分平台)。 > 需要手动修补 pub cache 中的文件,使它们兼容 win32 6.x API。 #### 2.9.1 需要修补的包 | 包名 | 版本 | 问题 | 修补文件 | |---|---|---|---| | quill_native_bridge_windows | 0.0.2 | 使用 win32 5.x API(`TEXT()`、`OpenClipboard(NULL)==FALSE` 等) | `lib/quill_native_bridge_windows.dart` + `lib/src/clipboard_html_format.dart` | | flutter_vibrate | gitcode | `TARGET_OS_SIMULATOR` 在 Xcode 16+/Swift 6 中不可用 | `ios/Classes/SwiftVibratePlugin.swift` | #### 2.9.2 补丁应用流程 ```bash # 1. 确保 flutter pub get 已执行 flutter pub get # 2. 运行补丁脚本(见 §2.6.3) bash scripts/patch_pub_cache.sh # 3. 验证构建 flutter build macos flutter build ios --no-codesign ``` > ⚠️ `flutter clean` 或 `flutter pub cache repair` 会清除补丁,需重新执行步骤 2。 #### 2.9.3 补丁脚本 在项目根目录创建 `scripts/patch_pub_cache.sh`(已包含在仓库中),内容如下: ```bash #!/bin/bash # patch_pub_cache.sh — 修补 pub cache 中的兼容性问题 # 创建时间: 2026-06-01 # 作用: 修补 quill_native_bridge_windows (win32 6.x) 和 flutter_vibrate (Swift 6) set -e echo "🔧 Applying pub cache patches..." # ── Patch 1: quill_native_bridge_windows — clipboard_html_format.dart ── QNBW_DIR="$HOME/.pub-cache/hosted/pub.dev/quill_native_bridge_windows-0.0.2" if [ -d "$QNBW_DIR" ]; then echo " Patching clipboard_html_format.dart..." cat > "$QNBW_DIR/lib/src/clipboard_html_format.dart" << 'DART_EOF' import 'package:ffi/ffi.dart'; import 'package:win32/win32.dart'; import '../quill_native_bridge_windows.dart'; const _kHtmlFormatName = 'HTML Format'; int? _cfHtml; extension ClipboardHtmlFormatExt on QuillNativeBridgeWindows { int? get cfHtml { _cfHtml ??= _registerHtmlFormat(); return _cfHtml; } int? _registerHtmlFormat() { final htmlFormatPointer = _kHtmlFormatName.toPcwstr(); final result = RegisterClipboardFormat(htmlFormatPointer); free(htmlFormatPointer); if (result.error.isError) { return null; } return result.value; } } DART_EOF echo " ✅ clipboard_html_format.dart patched" else echo " ⚠️ quill_native_bridge_windows not found in pub cache" fi # ── Patch 2: quill_native_bridge_windows — quill_native_bridge_windows.dart ── if [ -d "$QNBW_DIR" ]; then echo " Patching quill_native_bridge_windows.dart..." cat > "$QNBW_DIR/lib/quill_native_bridge_windows.dart" << 'DART_EOF' // Patched for win32 6.x compatibility (2026-06-01) // Changes: Win32Result API, HGLOBAL/HANDLE extension types, TEXT() → toPcwstr() import 'dart:ffi'; import 'dart:io'; import 'package:ffi/ffi.dart'; import 'package:file_selector_platform_interface/file_selector_platform_interface.dart'; import 'package:flutter/foundation.dart'; import 'package:quill_native_bridge_platform_interface/quill_native_bridge_platform_interface.dart'; import 'package:win32/win32.dart'; import 'src/clipboard_html_format.dart'; import 'src/html_cleaner.dart'; import 'src/html_formatter.dart'; import 'src/image_saver.dart'; class QuillNativeBridgeWindows extends QuillNativeBridgePlatform { static void registerWith() { QuillNativeBridgePlatform.instance = QuillNativeBridgeWindows(); } @override Future isSupported(QuillNativeBridgeFeature feature) async => { QuillNativeBridgeFeature.getClipboardHtml, QuillNativeBridgeFeature.copyHtmlToClipboard, QuillNativeBridgeFeature.saveImage, }.contains(feature); @override Future getClipboardHtml() async { final openResult = OpenClipboard(null); if (openResult.error.isError) { assert(false, 'Unknown error while opening the clipboard. Error code: ${GetLastError()}'); return null; } try { final htmlFormatId = cfHtml; if (htmlFormatId == null) { assert(false, 'Failed to register clipboard HTML format.'); return null; } final availableResult = IsClipboardFormatAvailable(htmlFormatId); if (availableResult.error.isError) { return null; } final getDataResult = GetClipboardData(htmlFormatId); if (getDataResult.error.isError) { assert(false, 'Failed to get clipboard data. Error code: ${GetLastError()}'); return null; } final clipboardDataHandle = getDataResult.value; final hglobal = HGLOBAL(clipboardDataHandle); final lockResult = GlobalLock(hglobal); if (lockResult.error.isError) { assert(false, 'Failed to lock global memory. Error code: ${GetLastError()}'); return null; } final lockedMemoryPointer = lockResult.value; final windowsHtmlWithMetadata = lockedMemoryPointer.cast().toDartString(); GlobalUnlock(hglobal); final cleanedHtml = stripWindowsHtmlDescriptionHeaders(windowsHtmlWithMetadata); return cleanedHtml; } finally { CloseClipboard(); } } @override Future copyHtmlToClipboard(String html) async { final openResult = OpenClipboard(null); if (openResult.error.isError) { assert(false, 'Unknown error while opening the clipboard. Error code: ${GetLastError()}'); return; } final windowsClipboardHtml = constructWindowsHtmlDescriptionHeaders(html); final htmlPointer = windowsClipboardHtml.toNativeUtf8(); try { final emptyResult = EmptyClipboard(); if (emptyResult.error.isError) { assert(false, 'Failed to empty the clipboard. Error code: ${GetLastError()}'); return; } final htmlFormatId = cfHtml; if (htmlFormatId == null) { assert(false, 'Failed to register clipboard HTML format. Error code: ${GetLastError()}'); return; } final unitSize = sizeOf(); final htmlSize = (htmlPointer.length + 1) * unitSize; final allocResult = GlobalAlloc(GMEM_MOVEABLE, htmlSize); if (allocResult.error.isError) { assert(false, 'Failed to allocate memory for the clipboard content. Error code: ${GetLastError()}'); return; } final clipboardMemoryHandle = allocResult.value; final lockResult = GlobalLock(clipboardMemoryHandle); if (lockResult.error.isError) { GlobalFree(clipboardMemoryHandle); assert(false, 'Failed to lock global memory. Error code: ${GetLastError()}'); return; } final lockedMemoryPointer = lockResult.value; final targetMemoryPointer = lockedMemoryPointer.cast(); final sourcePointer = htmlPointer.cast(); for (var i = 0; i < htmlPointer.length; i++) { targetMemoryPointer[i] = (sourcePointer + i).value; } (targetMemoryPointer + htmlPointer.length).value = 0; GlobalUnlock(clipboardMemoryHandle); final setResult = SetClipboardData(htmlFormatId, HANDLE(clipboardMemoryHandle)); if (setResult.error.isError) { GlobalFree(clipboardMemoryHandle); assert(false, 'Failed to set the clipboard data: ${GetLastError()}'); } } finally { CloseClipboard(); calloc.free(htmlPointer); } } @visibleForTesting static ImageSaver imageSaver = ImageSaver(); @override Future saveImage(Uint8List imageBytes, {required ImageSaveOptions options}) async { final typeGroup = XTypeGroup(label: 'Images', extensions: [options.fileExtension]); final saveLocation = await imageSaver.fileSelector.getSaveLocation( options: SaveDialogOptions( suggestedName: '${options.name}.${options.fileExtension}', initialDirectory: imageSaver.picturesDirectoryPath, ), acceptedTypeGroups: [typeGroup], ); final imageFilePath = saveLocation?.path; if (imageFilePath == null) { return ImageSaveResult.io(filePath: null); } final imageFile = File(imageFilePath); await imageFile.writeAsBytes(imageBytes); return ImageSaveResult.io(filePath: imageFile.path); } @override Future openGalleryApp() async { final uriPtr = 'ms-photos:'.toPcwstr(); final openPtr = 'open'.toPcwstr(); ShellExecute(null, openPtr, uriPtr, null, null, SW_SHOWNORMAL); free(uriPtr); free(openPtr); } } DART_EOF echo " ✅ quill_native_bridge_windows.dart patched" fi # ── Patch 3: flutter_vibrate — SwiftVibratePlugin.swift ── FV_DIR=$(find "$HOME/.pub-cache/git" -path "*/fluttertpc_flutter_vibrate*" -maxdepth 2 -type d 2>/dev/null | head -1) if [ -n "$FV_DIR" ]; then echo " Patching SwiftVibratePlugin.swift..." cat > "$FV_DIR/ios/Classes/SwiftVibratePlugin.swift" << 'SWIFT_EOF' import Flutter import UIKit import AudioToolbox // Patched for Xcode 16+/Swift 6 compatibility (2026-06-01) // TARGET_OS_SIMULATOR removed; using #if targetEnvironment(simulator) #if targetEnvironment(simulator) private let isDevice = false #else private let isDevice = true #endif public class SwiftVibratePlugin: NSObject, FlutterPlugin { public static func register(with registrar: FlutterPluginRegistrar) { let channel = FlutterMethodChannel(name: "vibrate", binaryMessenger: registrar.messenger()) let instance = SwiftVibratePlugin() registrar.addMethodCallDelegate(instance, channel: channel) } public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { switch (call.method) { case "canVibrate": if isDevice { result(true) } else { result(false) } case "vibrate": AudioServicesPlaySystemSound(kSystemSoundID_Vibrate) case "impact": if #available(iOS 10.0, *) { let impact = UIImpactFeedbackGenerator() impact.prepare() impact.impactOccurred() } else { AudioServicesPlaySystemSound(kSystemSoundID_Vibrate) } case "selection": if #available(iOS 10.0, *) { let selection = UISelectionFeedbackGenerator() selection.prepare() selection.selectionChanged() } else { AudioServicesPlaySystemSound(kSystemSoundID_Vibrate) } case "success": if #available(iOS 10.0, *) { let notification = UINotificationFeedbackGenerator() notification.prepare() notification.notificationOccurred(.success) } else { AudioServicesPlaySystemSound(kSystemSoundID_Vibrate) } case "warning": if #available(iOS 10.0, *) { let notification = UINotificationFeedbackGenerator() notification.prepare() notification.notificationOccurred(.warning) } else { AudioServicesPlaySystemSound(kSystemSoundID_Vibrate) } case "error": if #available(iOS 10.0, *) { let notification = UINotificationFeedbackGenerator() notification.prepare() notification.notificationOccurred(.error) } else { AudioServicesPlaySystemSound(kSystemSoundID_Vibrate) } case "heavy": if #available(iOS 10.0, *) { let generator = UIImpactFeedbackGenerator(style: .heavy) generator.prepare() generator.impactOccurred() } else { AudioServicesPlaySystemSound(kSystemSoundID_Vibrate) } case "medium": if #available(iOS 10.0, *) { let generator = UIImpactFeedbackGenerator(style: .medium) generator.prepare() generator.impactOccurred() } else { AudioServicesPlaySystemSound(kSystemSoundID_Vibrate) } case "light": if #available(iOS 10.0, *) { let generator = UIImpactFeedbackGenerator(style: .light) generator.prepare() generator.impactOccurred() } else { AudioServicesPlaySystemSound(kSystemSoundID_Vibrate) } default: result(FlutterMethodNotImplemented) } } } SWIFT_EOF echo " ✅ SwiftVibratePlugin.swift patched" else echo " ⚠️ flutter_vibrate not found in pub cache" fi echo "🎉 All patches applied!" echo "⚠️ Note: Run this script again after 'flutter clean' or 'flutter pub cache repair'" ``` #### 2.9.4 win32 5.x → 6.x API 迁移参考 如需修补其他使用 win32 的三方包,以下是关键 API 变更: | win32 5.x | win32 6.x | 说明 | |---|---|---| | `OpenClipboard(NULL) == FALSE` | `OpenClipboard(null).error.isError` | 返回 `Win32Result` | | `CloseClipboard()` | `CloseClipboard()` | 返回 `Win32Result`,忽略即可 | | `EmptyClipboard() == FALSE` | `EmptyClipboard().error.isError` | 返回 `Win32Result` | | `IsClipboardFormatAvailable(id) == FALSE` | `IsClipboardFormatAvailable(id).error.isError` | 返回 `Win32Result` | | `GetClipboardData(id) == NULL` | `GetClipboardData(id).error.isError` | 返回 `Win32Result` | | `GlobalAlloc(flags, size) == nullptr` | `GlobalAlloc(flags, size).error.isError` | 返回 `Win32Result` | | `GlobalLock(handle) == nullptr` | `GlobalLock(HGLOBAL(handle)).error.isError` | 返回 `Win32Result` | | `GlobalUnlock(handle)` | `GlobalUnlock(HGLOBAL(handle))` | 返回 `Win32Result` | | `GlobalFree(handle)` | `GlobalFree(HGLOBAL(handle))` | 返回 `Win32Result` | | `SetClipboardData(id, addr) == NULL` | `SetClipboardData(id, HANDLE(hglobal)).error.isError` | 返回 `Win32Result` | | `TEXT('str')` | `'str'.toPcwstr()` | `TEXT()` 已移除 | | `RegisterClipboardFormat(ptr)` 返回 `int` | `RegisterClipboardFormat(ptr)` 返回 `Win32Result` | 检查 `.error.isError` | | `ShellExecute(NULL, ...)` | `ShellExecute(null, ...)` | `NULL` → `null`,`nullptr` → `null`(PCWSTR? 类型) | | `HGLOBAL` 为 `int` | `HGLOBAL` 为 `extension type const HGLOBAL(Pointer _)` | 需类型转换 | | `HANDLE` 为 `int` | `HANDLE` 为 `extension type const HANDLE(Pointer _)` | 需类型转换 | > **类型转换**:`HANDLE` 和 `HGLOBAL` 都 `implements Pointer`,可互相转换: > - `HANDLE → HGLOBAL`:`HGLOBAL(handle)` > - `HGLOBAL → HANDLE`:`HANDLE(hglobal)` --- ## 三、Git 提交与合并规范 ### 3.1 分支策略 ``` main (受保护) ← 所有平台共用 ├── feature/xxx ← 功能开发(所有平台共用) ├── fix/xxx ← Bug修复 └── hotfix/xxx ← 紧急修复 ``` > **不需要按平台创建长期分支**。`lib/` 代码所有平台共用,独立平台分支会导致大量合并冲突。 > 平台原生代码已天然隔离(`ios/`、`macos/`、`ohos/` 各自独立),不会互相污染。 ### 3.2 iOS/macOS 开发者提交规则 **✅ 应该提交的文件:** - `lib/` 目录下的所有 Dart 代码 - `ios/` 目录下的原生代码 - `macos/` 目录下的原生代码 - `assets/` 目录下的资源文件 - `test/` 目录下的测试代码 **❌ 不要提交的文件:** - `pubspec.yaml`(MacBook Pro 端已替换为远程版本,**绝不能提交**) - `packages/` 目录(已在 `.gitignore` 中排除) - `ohos/` 目录下的鸿蒙原生代码 - 鸿蒙SDK特有的配置文件 **⚠️ pubspec.yaml 提交铁律:** 1. **`pubspec.yaml` 已加入 `.gitignore`,不应提交** — 由 `setup_pubspec.ps1` 脚本从模板生成 2. **修改依赖时编辑模板文件** — `pubspec.macos.yaml`(MacBook Pro 端)或 `pubspec.ohos.yaml`(鸿蒙端) 3. **模板文件必须提交** — 确保两端依赖配置同步 ### 3.3 pubspec.yaml 处理策略(⭐ 重点) > **双模板机制下,`pubspec.yaml` 由脚本生成,不提交到 Git。** > MacBook Pro 端开发者无需手动替换本地包引用,也无需 git stash 隔离。 #### 3.3.1 日常操作 ```bash # git pull 后,重新生成 pubspec.yaml 即可 .\tools\setup_pubspec.ps1 -Platform macos flutter pub get # 编译验证 flutter build ios --no-codesign ``` #### 3.3.2 需要新增依赖时 新增依赖时,必须在**两端模板**分别操作: ```bash # 1. 在 pubspec.macos.yaml 添加远程版本依赖 # 2. 在 pubspec.ohos.yaml 添加对应依赖(本地包或远程版本) # 3. 更新本文档 §2.3 差异对照表 # 4. 重新生成 pubspec.yaml .\tools\setup_pubspec.ps1 -Platform macos flutter pub get # 5. 通知鸿蒙开发者评估适配 ``` #### 3.3.3 git pull 后 pubspec.yaml 被覆盖 ```bash # pubspec.yaml 在 .gitignore 中,git pull 不会覆盖 # 如果误删或需要重新生成: .\tools\setup_pubspec.ps1 -Platform macos flutter pub get ``` #### 3.3.4 减少冲突的最佳实践 | 做法 | 说明 | |---|---| | **不提交 pubspec.yaml** | 已在 .gitignore,从根本上避免冲突 | | **新增依赖时通知鸿蒙开发者** | 让鸿蒙端同步评估适配 | | **版本号升级单独提交** | 不要和功能代码混在一起提交 | | **修改模板而非 pubspec.yaml** | 直接编辑 `pubspec.macos.yaml` / `pubspec.ohos.yaml` | ### 3.4 其他合并注意事项 1. **不要删除 `pu.isOhos` 相关代码**:这些条件分支在 iOS/macOS 上不会执行,但删除会导致鸿蒙端编译失败 2. **不要修改 `OhosAppShell`、`OhosNavBridge` 等鸿蒙专用类**:这些类仅在鸿蒙端使用 3. **新增页面路由时**:需同时在 `app_router.dart`(GoRouter路由表)和 `ohos_nav_bridge.dart`(鸿蒙路由映射表)中注册 ### 3.5 PR 审查要点 - [ ] 未提交 `pubspec.yaml`(已加入 .gitignore,由脚本生成) - [ ] 依赖变更已同步更新 `pubspec.macos.yaml` 和 `pubspec.ohos.yaml` 两个模板 - [ ] 未引入 `TargetPlatform` exhaustive switch 问题 - [ ] 新增路由已在 `ohos_nav_bridge.dart` 中同步 - [ ] `lib/` 代码无平台特定硬编码(应使用 `platform_utils.dart`) - [ ] 新增依赖已通知鸿蒙开发者评估适配 --- ## 四、TargetPlatform.ohos 处理 ### 4.1 问题背景 鸿蒙定制 Flutter SDK 在 `TargetPlatform` 枚举中新增了 `TargetPlatform.ohos`。 官方 SDK 不包含此值。本地包中引用了 `TargetPlatform.ohos`,在官方 SDK 下会编译报错。 **MacBook Pro 端的解决方案**:使用远程版本的三方库,远程版本不含 `TargetPlatform.ohos`,无需关心此问题。 ### 4.2 lib/ 代码中的平台判断 `lib/` 目录下的项目代码使用 `platform_utils.dart` 进行平台判断,**不依赖 `TargetPlatform.ohos` 枚举**, 在官方 SDK 下编译完全正常: ```dart // lib/ 代码使用 Platform.operatingSystem 字符串比较,不依赖枚举 // platform_io_native.dart: bool _isOhos() { try { return Platform.operatingSystem == 'ohos'; // 字符串比较,官方SDK也支持 } catch (_) { return false; } } ``` ### 4.3 iOS/macOS 开发者规范 **规则1:不要使用 `switch(TargetPlatform)` 穷举匹配** ```dart // ❌ 错误 — 官方SDK没有 ohos,鸿蒙SDK缺少 ohos 会报错 switch (defaultTargetPlatform) { case TargetPlatform.iOS: // ... case TargetPlatform.android: // ... case TargetPlatform.macOS: // ... case TargetPlatform.windows: // ... case TargetPlatform.linux: // ... case TargetPlatform.fuchsia: // ... } // ✅ 正确 — 使用 if-else 或添加 default if (defaultTargetPlatform == TargetPlatform.iOS) { // iOS 逻辑 } else if (defaultTargetPlatform == TargetPlatform.macOS) { // macOS 逻辑 } else { // 其他平台 } // ✅ 正确 — switch 加 default switch (defaultTargetPlatform) { case TargetPlatform.iOS: case TargetPlatform.android: return mobileLayout; default: return desktopLayout; } ``` **规则2:使用 `platform_utils.dart` 判断平台** ```dart import 'package:xianyan/core/utils/platform_utils.dart' as pu; // ✅ 推荐方式 — 内部使用字符串比较,两端都安全 if (pu.isIOS) { /* iOS */ } if (pu.isMacOS) { /* macOS */ } if (pu.isOhos) { /* 鸿蒙 */ } if (pu.isMobile) { /* 移动端 */ } if (pu.isDesktop) { /* 桌面端 */ } ``` ### 4.4 已知的 TargetPlatform.ohos 适配点(仅本地包) 以下文件在本地包中引用了 `TargetPlatform.ohos`,MacBook Pro 端使用远程版本不受影响: | 文件 | 位置 | 引用类型 | |---|---|---| | `packages/flex_color_picker/.../picker_functions.dart` | L49, L65 | `case TargetPlatform.ohos:` | | `packages/flutter_quill/.../raw_editor_state.dart` | L309 | `case TargetPlatform.ohos:` | | `packages/flutter_quill/.../link.dart` | L58 | `case TargetPlatform.ohos:` | | `packages/flutter_local_notifications/.../plugin.dart` | 8处 | `== TargetPlatform.ohos` | | `packages/mobile_scanner/.../method_channel.dart` | 3处 | `== / != TargetPlatform.ohos` | | `packages/audioplayers/.../audioplayer.dart` | L178 | `!= TargetPlatform.ohos` | > 以上文件均在 `packages/` 目录中,MacBook Pro 端使用远程版本,不会编译这些文件。 ### 4.5 lib/ 代码中的鸿蒙SDK类型桥接 鸿蒙端本地包中某些类有官方 SDK 不存在的额外参数或类型(如 `OhosInitializationSettings`、`ohosName`)。 项目通过桥接文件和 `dynamic` 调用隔离这些差异,确保两端编译正常。 #### 4.5.1 通知服务桥接 **桥接文件**:`lib/core/services/notification/notification_init_stub.dart` | 方法 | 官方SDK行为 | 鸿蒙端行为 | |---|---|---| | `buildNotificationInitSettings()` | 构建 `InitializationSettings`(无 ohos 参数) | 鸿蒙端本地包的 `InitializationSettings` 自带 ohos 参数,官方端不传即可 | | `requestOhosNotificationPermission()` | 返回 `false`(不执行) | 动态调用 `OhosFlutterLocalNotificationsPlugin` | **使用此桥接的文件**: - `lib/core/services/notification/notification_service.dart` - `lib/core/services/notification/local_notification_service.dart` - `lib/features/file_transfer/services/notification_service.dart` #### 4.5.2 HomeWidget 桥接 鸿蒙端本地包的 `HomeWidget.updateWidget()` 和 `HomeWidget.requestPinWidget()` 有 `ohosName` 参数, 官方 SDK 不存在此参数。项目通过 `pu.isOhos` 条件 + `dynamic` 调用隔离: ```dart // 官方SDK:标准调用 await HomeWidget.updateWidget( androidName: type.androidProviderName, iOSName: type.iosWidgetKind, ); // 鸿蒙端:dynamic 调用,传入 ohosName if (pu.isOhos) { final dynamic updateWidget = HomeWidget.updateWidget; await updateWidget( androidName: type.androidProviderName, iOSName: type.iosWidgetKind, ohosName: type.ohosFormName, ); } ``` **涉及文件**: - `lib/core/services/data/home_widget_service.dart` - `lib/features/widget/providers/widget_provider.dart` #### 4.5.3 新增鸿蒙SDK特有类型的规范 如果鸿蒙端本地包新增了官方 SDK 不存在的类型或参数,按以下规范处理: 1. **在 `lib/` 代码中不直接 import 鸿蒙专用类型**(如 `OhosInitializationSettings`) 2. **使用 `pu.isOhos` 条件分支**:鸿蒙端逻辑仅在 `pu.isOhos` 为 true 时执行 3. **使用 `dynamic` 调用**:绕过官方 SDK 的静态类型检查 4. **优先创建桥接文件**:将鸿蒙特有逻辑封装在独立文件中(如 `notification_init_stub.dart`) --- ## 五、其他注意事项 ### 5.1 应用入口架构差异 项目在 `app.dart` 中根据平台选择不同的应用架构: | 平台 | 应用架构 | 导航方式 | 入口Widget | |---|---|---|---| | iOS/macOS/Android | `MaterialApp.router` + GoRouter | GoRouter 声明式路由 | `AppShell` | | 鸿蒙 | `MaterialApp(home:)` + Navigator | `CupertinoTabView` + Navigator.push | `OhosAppShell` | **原因**:鸿蒙端 `MaterialApp.router` 白屏,无法使用 GoRouter,因此使用传统 Navigator 导航。 ### 5.2 路由注册双写 新增页面时,必须在两处注册路由: 1. **`lib/core/router/app_router.dart`** — GoRouter 路由表(iOS/macOS/Android 使用) 2. **`lib/core/router/ohos_nav_bridge.dart`** — 鸿蒙路由映射表 ```dart // app_router.dart GoRoute( path: AppRoutes.newPage, name: 'new-page', parentNavigatorKey: rootNavigatorKey, pageBuilder: (context, state) => iosSlideTransition(state: state, child: const NewPage()), ), // ohos_nav_bridge.dart AppRoutes.newPage: (_) => const NewPage(), ``` ### 5.3 平台特性检测 使用 `OhosDeviceCapabilities` 检测鸿蒙设备特性(毛玻璃、液态玻璃、重度动画、折叠屏等), iOS/macOS 端这些检测不会执行(`isOhos` 为 false),无需关心。 ### 5.4 packages 目录说明 - `packages/` 目录存放鸿蒙适配的本地三方库,已在 `.gitignore` 中排除(`/packages/`) - MacBook Pro 端使用远程版本,无需 `packages/` 目录 - 鸿蒙开发者需手动维护本地 `packages/` 目录 - ⚠️ `pubspec.yaml` 已加入 `.gitignore`,不再提交到 Git - 鸿蒙端模板:`pubspec.ohos.yaml`,MacBook Pro 端模板:`pubspec.macos.yaml` - 使用 `tools/setup_pubspec.ps1` 生成 `pubspec.yaml` ### 5.5 MacBook Pro 修改 ios/macos 后,鸿蒙端是否需要同步? | 修改内容 | 鸿蒙端是否需要同步 | 原因 | |---|---|---| | `ios/` 目录 | ❌ 不需要 | 平台原生代码,鸿蒙不使用 | | `macos/` 目录 | ❌ 不需要 | 平台原生代码,鸿蒙不使用 | | `lib/` 目录 | ✅ 需要同步 | Dart 代码所有平台共用 | | `pubspec.ohos.yaml` | ✅ 鸿蒙端模板 | 鸿蒙端依赖配置 | | `pubspec.macos.yaml` | ✅ MacBook Pro端模板 | MacBook Pro端依赖配置 | | `pubspec.yaml` | ❌ 不提交 | 已加入 .gitignore,本地生成 | | `assets/` 目录 | ✅ 需要同步 | 资源文件共享 | > **实际案例**:`bitsdojo_window → window_manager` 迁移时,MacBook Pro 端先完成代码和依赖替换, > 鸿蒙端随后同步更新 `pubspec.yaml`(移除 `bitsdojo_window`,添加 `window_manager`), > 并删除 `packages/bitsdojo_window_windows/` 废弃目录。 ### 5.6 常见问题 | 问题 | 原因 | 解决方案 | |---|---|---| | `flutter pub get` 报 packages/xxx 目录不存在 | pubspec.yaml 是鸿蒙端模板 | MacBook Pro端运行 `.\tools\setup_pubspec.ps1 -Platform macos` | | 编译报 `TargetPlatform.ohos` 不存在 | 使用了含 ohos 引用的本地包 | 确认使用 `pubspec.macos.yaml` 生成的 pubspec.yaml | | iOS 编译报 ohos 相关错误 | 误用鸿蒙SDK编译iOS | 切换到官方 Flutter SDK | | GoRouter 路由正常但鸿蒙端白屏 | 鸿蒙端不支持 GoRouter | 检查 OhosNavBridge 路由映射 | | git pull 后 pubspec.yaml 被覆盖 | pubspec.yaml 已在 .gitignore | 重新运行 `.\tools\setup_pubspec.ps1` | | 新增依赖后另一端报错 | 只更新了一个模板 | 必须同时更新两个模板 + 文档,参见 §2.4 | | 新增依赖后鸿蒙端报错 | 新增的三方库未适配鸿蒙 | 通知鸿蒙开发者评估,必要时本地化到 packages/ | | 编译报 `OhosInitializationSettings` 不存在 | 官方SDK无此类型 | 使用 `notification_init_stub.dart` 桥接,参见 §4.5 | | 编译报 `ohosName` 参数不存在 | 官方SDK的 HomeWidget 无此参数 | 使用 `dynamic` 调用,参见 §4.5.2 | | `pro_image_editor` 报 `CanvasStyleModel` 不存在 | 远程版本不含魔改内容 | 使用本地包 `path: packages/pro_image_editor`,参见 §2.8.1 | | `FilePicker.platform` 报错 | file_picker 11.x API 变更 | 使用 `FilePicker.pickFiles()`,参见 §2.8.2 | --- ## 六、快速检查清单 MacBook Pro 开发前,确认以下事项: - [ ] 使用官方 Flutter SDK(非 flutter-ohos) - [ ] 已 `git clone` 拉取最新代码 - [ ] 已运行 `.\tools\setup_pubspec.ps1 -Platform macos` 生成 pubspec.yaml - [ ] `flutter pub get` 无报错 - [ ] `dart analyze lib/` 无 error - [ ] 新增代码未使用 `switch(TargetPlatform)` 穷举匹配 - [ ] 新增鸿蒙SDK特有类型已通过桥接文件隔离(参见 §4.5) - [ ] 新增路由已在 `app_router.dart` 和 `ohos_nav_bridge.dart` 双写 - [ ] 依赖变更已同步更新两个模板文件 - [ ] Git 提交未删除 `pu.isOhos` 相关代码 --- *文档创建时间: 2026-05-21 | 更新时间: 2026-06-07 v10 | 维护者: 闲言APP开发团队* *更新内容: 修正dependency_overrides行数、补丁引用;简化pro_image_editor和pubspec.yaml处理策略;与双模板机制对齐*