Flutter document_opener 鸿蒙平台补全:保持 Dart 接口不变,ArkTS 实现 startAbility 临时读权,合规打开 PDF、CSV
开发工具: 华为云码道
DevEco Studio:
26.0.0(26.0.0.851)原始仓库: julien-kv/document_opener(GitHub)
本文配套仓库: oh-flutter/document_opener(AtomGit)
document_opener 只做一件事:把应用沙箱里的 PDF 或 CSV 文件交给系统查看器打开,并且不申请任何宽泛的媒体权限。它不解析文档内容,也不内置阅读器——真正的渲染由系统应用负责,插件只负责「校验 + 生成 URI + 把 URI 的临时读权限授予目标应用 + 发起查看」。本文以 document_opener 1.0.3 为例,介绍源码准备、OHOS 平台补全、ArkTS 实现和 example 真机运行验证。

插件目前支持 ohos 平台,源码位于 AtomGit 配套仓库。文中的代码以提交 52b5c929e5709441836bbd43af93b9e7e0b8d135 为参考,对应 TAG 1.0.3。

一、插件简介与适配目标
document_opener 的公开 API 只有一个静态方法:
| 能力 | 入口 | 说明 |
|---|---|---|
| 打开文档 | DocumentOpener.open(filePath) | 只接受 .pdf 与 .csv,返回 DocumentOpenResult |
| 结果模型 | DocumentOpenResult.type / .message | 类型为 DocumentResultType 枚举,消息为原生返回的字符串 |
DocumentResultType 有四个取值:done、error、noAppToOpen、permissionDenied。业务侧通常只需要判断 type == DocumentResultType.done,失败时把 message 展示给用户即可。
这个库的设计取舍值得先说明,因为它直接决定了鸿蒙适配的形态:
- 不做文件类型嗅探,只看扩展名。
_isValidFileType判断字符串是否以.pdf或.csv结尾,不看文件头。 - 不申请存储权限。 Android 侧用
FileProvider把应用私有目录下的文件映射成content://URI,再给目标应用授予一次性读权限;OpenHarmony 侧用fileUri.getUriFromPath生成file://URI,再用FLAG_AUTH_READ_URI_PERMISSION做同样的事。 - 原生侧只负责「交出去」。 文件打开之后的渲染、翻页、分享,全部由系统应用承担。因此插件返回值里的
done语义是「已经成功移交给系统查看器」,不是「用户已经看完」。
需要强调第三点:done 只代表 startAbility 没有抛异常。用户是否真的看到内容,取决于系统里有没有能处理该 MIME 类型的应用,以及那个应用能否读到这个 URI。
本次适配的目标就是把上述语义在 OpenHarmony 上等价实现,同时保持 Dart 层 API、返回结构、错误分类完全不变。
二、环境准备
环境搭建参考社区文档:Flutter OH 开发环境搭建,完成 Flutter OH SDK 安装、环境变量和 DevEco Studio 配置。
完成后,在宿主机终端执行以下命令,确认当前选中的是支持 OHOS 的 Flutter 工具链,并能发现目标设备:
flutter --version
flutter doctor -v
hdc list targets


工程使用的工具链和 SDK 配置如下:
| 项目 | 版本或配置 | 用途 |
|---|---|---|
| Flutter OHOS SDK | 3.47.7-ohos-1.0.0 | Flutter 编译与 OHOS 平台工具链 |
| Flutter 分支 | oh-3.47-dev | CPF-Flutter 对应开发分支 |
| Dart SDK | 3.13.3 | Dart 语言与包管理环境(example 声明 sdk: ^3.13.3) |
| DevEco Studio | 26.0.0(26.0.0.851) | 工程 Sync、签名配置与 hvigor 构建 |
| HarmonyOS 开发套件 | 6.1.1(24) | 开发套件版本及对应的 API 级别 |
compatibleSdkVersion | 6.1.1(24) | 当前工程声明的最低兼容版本 |
targetSdkVersion | 6.1.1(24) | 应用面向的行为版本 |
| 插件版本 | 1.0.3 | pubspec.yaml 中的包版本 |
| 原生语言 | ArkTS | HarmonyOS 插件实现 |
| 插件产物 | HAR | 被应用 entry 模块依赖 |
| 验证设备 | TLR-AL00,OpenHarmony-6.1.1.120,API 24,arm64-v8a | 真机运行与截图 |
2.1 开发套件版本与工程中的 SDK 版本配置
6.1.1(24) 和 24 涉及 HarmonyOS 开发套件版本(API 版本)及其底座 OpenHarmony 的版本号体系。在本文工程中,相关版本的含义与配置方式如下:
6.1.1(24)表示开发套件版本为6.1.1,对应 API 24。- 本文
example/ohos/build-profile.json5中compatibleSdkVersion与targetSdkVersion都写成6.1.1(24),即「用 API 24 编译、以 API 24 为目标、最低兼容 API 24」。 - 本机安装的 DevEco Studio 自带 SDK 为 API 26,编译时向下兼容 API 24 的工程;但运行时只看设备实际能力。
对应的 product 配置为:
{
"name": "default",
"signingConfig": "default",
"compatibleSdkVersion": "6.1.1(24)",
"targetSdkVersion": "6.1.1(24)",
"runtimeOS": "HarmonyOS"
}

本工程没有显式写
compileSdkVersion,此时 hvigor 按 DevEco Studio 当前安装的 SDK 编译。不同 DevEco 版本对缺省行为的处理可能不同,需要固定编译 SDK 时再显式补上该字段。
三、从源码仓库开始准备适配工程
3.1 将上游源码同步到 AtomGit
适配已有第三方插件时,先从 Pub 包信息或项目 README 确认上游地址、包名、版本和许可证。document_opener 的上游是 GitHub 上的 julien-kv/document_opener,许可证为 MIT(Copyright (c) 2024 Scanpay),版本 1.0.3。
在 AtomGit 网页的新建仓库流程中,找到从已有仓库导入的入口,填写上游 Git 地址,选择自己有写权限的组织或个人空间,设置目标仓库名称后执行导入。完成后检查目标分支、pubspec.yaml、LICENSE 和提交历史是否完整,并记录适配起点的提交号。本次适配的起点是上游 a1c07db(Bump up version to 1.0.3)。
同步仓库时保留源码、许可证和提交历史,方便后续跟踪上游更新。document_opener 现可从 AtomGit 获取。
3.2 将代码拉取到宿主机
在安装了 Flutter OH 和 DevEco Studio 的开发电脑上打开终端,进入准备存放项目的目录,执行:
git clone https://atomgit.com/oh-flutter/document_opener.git
cd document_opener
pwd
ls
git remote -v
git status --short --branch
git rev-parse HEAD
git clone 会创建 document_opener/ 目录;cd 后的位置就是下文所说的插件仓库根目录,这里应能看到 pubspec.yaml、lib/、ohos/ 和 example/。这个库的仓库名与 Dart 包名一致,都是 document_opener,不需要额外传 --project-name 做名称映射。

图 1:在宿主机终端输入 AtomGit 仓库拉取命令并查看目录内容。
上游本身没有 ohos/ 目录,也没有 example/,只有 android/、ios/ 和 lib/。因此拉取后不能直接构建,需要先补全 OHOS 平台结构。
3.3 确认分支与版本标记
适配工作可以在 main 上直接完成,也可以先开分支。本次适配的提交 52b5c92 落在 main,并且已经与 origin/main 同步:
git branch --show-current
git tag --list
git log --oneline -3
git remote -v
git status --short --branch

图 2:确认当前分支、TAG、提交历史与双远端配置。
图 2 里有几个细节值得说明:
git tag --list同时列出上游的v1.0.3和本次适配的1.0.3-ohos-1.0.0-beta.1。前者是上游历史版本,后者是适配版本,两类标记并存是正常现象。origin指向 AtomGit 的oh-flutter/document_opener,upstream仍指向 GitHub 上游,便于后续git fetch upstream跟踪更新。git status --short --branch输出## main...origin/main,表示工作区干净且与远端一致。
如果按 PR 流程协作,建议改成先建分支再提交,分支名统一使用 feat/ohos_库名称_版本号,本例为 feat/ohos_document_opener_1.0.3:
git switch -c feat/ohos_document_opener_1.0.3
git branch --show-current
本次适配的 TAG 只在本地创建,尚未推送:
git tag -a 1.0.3-ohos-1.0.0-beta.1 -m "document_opener 1.0.3 OpenHarmony adaptation beta.1" 52b5c92
git push origin 1.0.3-ohos-1.0.0-beta.1
3.4 自动补全 OHOS 适配结构
仍在插件根目录执行结构补全。以下命令适用于尚无 ohos/ 目录的既有 Flutter 平台插件:
flutter create --template=plugin --platforms=ohos --org com.scanpay .
git status --short
git diff -- pubspec.yaml lib example
--template=plugin指定插件模板。--platforms=ohos指定只补全 OHOS 平台。--org com.scanpay与上游 Android 侧的com.scanpay.document_opener包名保持一致。这一点很重要:如果这里换了组织名,pubspec.yaml中 Android 的package与ohos的插件类会分属两套命名,后续排查通道问题会多一层干扰。- 最后的
.表示在当前插件目录补全工程,不是另建一层document_opener/。
该命令生成 OHOS 平台脚手架,业务逻辑需要在 ArkTS 中实现。生成后通过 diff 检查 pubspec.yaml、lib/ 和 example/ 的变化,保留已有 API、其他平台注册项及依赖配置。不同 Flutter OH 版本生成的模板可能略有差异——本例生成后 pubspec.yaml 只增加了两行:
@@ -24,3 +24,5 @@ flutter:
ios:
pluginClass: DocumentOpenerPlugin
+ ohos:
+ pluginClass: DocumentOpenerPlugin

图 3:补全 OHOS 结构后,ohos/ 与 example/ohos/ 出现在未跟踪文件列表,pubspec.yaml 只增加两行。
如果生成后 example/ohos/ 仍不存在,进入已有示例应用补全平台:
cd example
flutter create --platforms=ohos .
cd ..
配套仓库已经包含 ohos/ 和 example/ohos/,直接运行示例时可以跳过结构补全。
3.5 适配后的项目目录
适配后的关键目录如下:
document_opener/
├── lib/
│ └── document_opener.dart
├── ohos/
│ ├── index.ets
│ ├── oh-package.json5
│ ├── build-profile.json5
│ ├── hvigorfile.ts
│ └── src/main/
│ ├── module.json5
│ └── ets/components/plugin/
│ └── DocumentOpenerPlugin.ets
├── android/src/main/
│ ├── AndroidManifest.xml
│ ├── res/xml/file_paths.xml
│ └── kotlin/.../DocumentOpenerPlugin.kt
├── example/
│ ├── lib/main.dart
│ └── ohos/
├── README.OpenHarmony_CN.md
├── CHANGELOG.OpenHarmony.md
└── pubspec.yaml

图 4:适配后的项目结构。
与「lib/ 下多个文件 + 平台接口抽象」的插件不同,document_opener 的全部 Dart 逻辑都在单个 document_opener.dart(113 行)里,没有 *_platform_interface.dart、*_method_channel.dart 这一套分层。这是上游自己的选择,适配时不要为了「对齐模板」去改结构。
| 文件 | 主要职责 |
|---|---|
lib/document_opener.dart | 公开 API、扩展名校验、平台分支判断、结果解析 |
DocumentOpenerPlugin.ets | 注册方法通道,校验路径与文件存在性,发起 startAbility |
插件 module.json5 | 声明 HAR 模块类型与设备类型(本插件不申请权限) |
示例 entry module.json5 | 声明宿主应用 Ability、设备类型与权限场景 |
example/lib/main.dart | 生成样例文件、触发四种打开场景、展示结果记录 |
四、Dart 接口与通道分析
OHOS 实现需要遵循 Dart 层已有的方法名、参数键和返回结构。先读 lib/document_opener.dart 的平台分支,再看 ArkTS 侧要补什么。
实际的调用关系只有一条链路:
| Dart 入口或模型 | 通道协议 | OHOS 实现 | 应保持的行为 |
|---|---|---|---|
DocumentOpener.open(filePath) | com.scanpay.document_opener / openDocument | fileUri.getUriFromPath + startAbility | 只接受 .pdf / .csv,成功移交返回 done |
参数 file_path | Map<String, String> | call.argument('file_path') as string | 键名必须与 Android 的 call.argument<String>("file_path") 一致 |
DocumentOpenResult | 原生返回 JSON 字符串 | JSON.stringify({type, message}) | Dart 侧执行 json.decode(result),不能返回对象 |
DocumentResultType 四枚举 | type 字段的字符串值 | done / error / noAppToOpen | 未知取值会被解析为 error |
4.1 跨端架构与调用时序
这条链路没有事件流,只有一次性的方法调用:
4.1.1 一次完整打开的时序
4.2 平台分支:这次适配唯一需要改的 Dart 代码
理解这次适配的关键,是看清上游的平台判断写成了什么形状:
// 上游 1.0.3 的写法
if (!Platform.isIOS && !Platform.isAndroid) {
// 桌面分支:macOS 走 open,Windows 走 cmd /c start,Linux 走 xdg-open
...
} else {
throw UnsupportedError("Unsupported platform");
}
}
// iOS、Android 才走方法通道
final result = await _channel.invokeMethod('openDocument', {...});
这段逻辑的隐含假设是「非 iOS 非 Android 的平台 = 桌面平台」。OpenHarmony 不满足这个假设:它既不是 iOS/Android,也不是 macOS/Windows/Linux,于是会落入桌面分支,在三个 else if 都不匹配之后执行 throw UnsupportedError("Unsupported platform")。
这个异常在 Dart 层抛出,根本到不了方法通道,所以不会出现 MissingPluginException,也不会在 hilog 里留下任何插件日志——表现就是「点一下就报错,原生侧完全没收到调用」。
修复只需要放宽条件:
// Handle desktop platforms
// 注意:OpenHarmony 不是桌面平台,必须走下面的方法通道;
// 若不排除 Platform.isOhos,OHOS 会落入 desktop 分支并抛出 UnsupportedError。
if (!Platform.isIOS && !Platform.isAndroid && !Platform.isOhos) {
...
}
// For iOS, Android and OpenHarmony, use platform channel
try {
final result = await _channel.invokeMethod('openDocument', {
"file_path": filePath,
});
...
}
整个 Dart 文件的改动是 2 行代码 + 2 行注释,公开 API 一个字符都没动。这也说明:跨平台插件的适配难度,往往不在「实现能力」,而在「找到那处把平台穷举成两类的假设」。
Platform.isOhos由 Flutter OH 工具链提供。如果用了不带该扩展的官方 Flutter SDK,这个符号不存在,代码无法编译——这也是必须使用 OHOS 版 Flutter 的原因之一。
4.3 结果模型与 JSON 契约
原生侧返回的不是对象,而是字符串:
final result = await _channel.invokeMethod('openDocument', {
"file_path": filePath,
});
final resultMap = json.decode(result) as Map<String, dynamic>;
return DocumentOpenResult.fromJson(resultMap);
这意味着 ArkTS 侧必须自己序列化。如果按直觉写成 result.success(payload) 传一个对象过去,Dart 侧 json.decode 会直接抛类型错误。ArkTS 里的写法因此固定为:
private createResult(type: string, message: string): string {
const payload: Record<string, string> = { 'type': type, 'message': message };
return JSON.stringify(payload);
}
_parseResultType 对未知 type 一律兜底为 error,所以即使将来原生侧新增了取值,Dart 层也不会崩,只是归类到 error。
4.4 permissionDenied 的现状
DocumentResultType.permissionDenied 在 Dart 层被定义、被 _parseResultType 解析,但没有任何原生实现会产生它:
- Android 侧
DocumentOpenerPlugin.kt只返回error、noAppToOpen、done; - OpenHarmony 侧同样只返回这三个值。
它是上游留下的预留取值。实际使用中,如果系统真的拒绝了 URI 授权,你会拿到 error,message 来自系统异常。这一点在 README 里也有说明,适配时保持一致即可,不要为了「让四枚举都有出处」而自行发明返回值。
五、补全 OHOS 原生实现与工程配置
5.1 在 DocumentOpenerPlugin.ets 中实现原生能力
以 openDocument 为例:业务仍调用 DocumentOpener.open(path),Dart 通道仍发送同名命令。需要补全的是 DocumentOpenerPlugin.ets 中的对应分支:校验路径、生成 file URI、发起 startAbility,再把结果序列化成 JSON 字符串回传。
DocumentOpenerPlugin 同时实现 FlutterPlugin、MethodCallHandler 和 AbilityAware:
export default class DocumentOpenerPlugin implements FlutterPlugin, MethodCallHandler, AbilityAware {
private channel: MethodChannel | null = null;
private abilityContext: common.UIAbilityContext | null = null;
}
原生插件位于:
ohos/src/main/ets/components/plugin/DocumentOpenerPlugin.ets
5.1.1 引入 Flutter 和 HarmonyOS 能力
import {
FlutterPlugin,
FlutterPluginBinding,
AbilityAware,
AbilityPluginBinding,
MethodCall,
MethodCallHandler,
MethodChannel,
MethodResult,
} from '@ohos/flutter_ohos';
import UIAbility from '@ohos.app.ability.UIAbility';
import Want from '@ohos.app.ability.Want';
import wantConstant from '@ohos.app.ability.wantConstant';
import common from '@ohos.app.ability.common';
import fileUri from '@ohos.file.fileuri';
import fs from '@ohos.file.fs';
import { BusinessError } from '@ohos.base';
import hilog from '@ohos.hilog';
其中:
FlutterPlugin负责接入 Flutter Engine 生命周期;MethodChannel接收 Dart 发来的命令;AbilityAware/AbilityPluginBinding提供 UIAbility 上下文,startAbility必须依赖它;fileUri把沙箱路径转成file://URI;fs用accessSync判断文件是否存在;wantConstant.Flags提供授权旗标常量;BusinessError用于读取错误码,hilog用于原生侧诊断。
5.1.2 连接 Flutter Engine
onAttachedToEngine(binding: FlutterPluginBinding): void {
try {
this.channel = new MethodChannel(binding.getBinaryMessenger(), CHANNEL_NAME);
this.channel.setMethodCallHandler(this);
hilog.info(DOMAIN, TAG, 'attached to engine, channel [%{public}s] registered', CHANNEL_NAME);
} catch (err) {
const error = err as BusinessError;
hilog.error(DOMAIN, TAG, 'onAttachedToEngine failed, code: %{public}d, message: %{public}s',
error.code, error.message);
}
}
onDetachedFromEngine(binding: FlutterPluginBinding): void {
try {
if (this.channel != null) {
// 置空 handler,避免引擎销毁后插件实例仍被通道持有而无法释放
this.channel.setMethodCallHandler(null);
this.channel = null;
}
hilog.info(DOMAIN, TAG, 'detached from engine');
} catch (err) {
const error = err as BusinessError;
hilog.error(DOMAIN, TAG, 'onDetachedFromEngine failed, code: %{public}d, message: %{public}s',
error.code, error.message);
}
}
5.1.3 取得 UIAbility 上下文
Android 侧用 ActivityAware 拿 Activity,OpenHarmony 侧用 AbilityAware 拿 UIAbilityContext。没有这个上下文就无法 startAbility,语义完全对应:
onAttachedToAbility(binding: AbilityPluginBinding): void {
try {
const ability: UIAbility = binding.getAbility();
this.abilityContext = ability.context;
hilog.info(DOMAIN, TAG, 'attached to ability');
} catch (err) {
const error = err as BusinessError;
hilog.error(DOMAIN, TAG, 'onAttachedToAbility failed, code: %{public}d, message: %{public}s',
error.code, error.message);
}
}
onDetachedFromAbility(): void {
// 释放对 UIAbility 上下文的引用,避免内存泄漏
this.abilityContext = null;
hilog.info(DOMAIN, TAG, 'detached from ability');
}
工程没有配置 Ability 场景或使用了错误的 Ability 类型(例如把插件挂在非 UIAbility 上),这一步就拿不到上下文,后续 openDocument 会返回 Ability is not available。
5.1.4 处理 MethodChannel 命令
onMethodCall(call: MethodCall, result: MethodResult): void {
try {
hilog.debug(DOMAIN, TAG, 'onMethodCall: %{public}s', call.method);
if (call.method == METHOD_OPEN_DOCUMENT) {
const filePath: string = call.argument(ARG_FILE_PATH) as string;
this.handleOpenDocument(filePath, result);
} else {
result.notImplemented();
}
} catch (err) {
const error = err as BusinessError;
hilog.error(DOMAIN, TAG, 'onMethodCall [%{public}s] failed, code: %{public}d, message: %{public}s',
call.method, error.code, error.message);
result.error(ERR_CODE, 'Failed to handle ' + call.method + ': ' + error.message, error.message);
}
}
result.notImplemented() 对应 Dart 侧的 MissingPluginException。如果方法名拼错,这里会走进 else 分支。
5.1.5 校验顺序与 Android 保持一致
private handleOpenDocument(filePath: string, result: MethodResult): void {
if (filePath == null || filePath.length == 0) {
result.success(this.createResult('error', 'File path cannot be null'));
return;
}
if (!this.isValidFileType(filePath)) {
result.success(this.createResult('error', 'Only PDF and CSV files are supported'));
return;
}
let fileExists: boolean = false;
try {
fileExists = fs.accessSync(filePath);
} catch (err) {
const error = err as BusinessError;
hilog.error(DOMAIN, TAG, 'access file failed, code: %{public}d, message: %{public}s',
error.code, error.message);
fileExists = false;
}
if (!fileExists) {
result.success(this.createResult('error', 'File does not exist'));
return;
}
if (this.abilityContext == null) {
result.success(this.createResult('error', 'Ability is not available'));
return;
}
this.startViewData(filePath, result);
}
五步顺序是刻意设计的:路径非空 → 扩展名合法 → 文件存在 → 上下文可用 → 发起调用。前一步不通过就不往下走,这样每种失败都有明确且唯一的 message,用户看到的提示能直接对应到问题所在。
fs.accessSync 在文件不存在时抛异常而不是返回 false,所以必须包在 try/catch 里,并在 catch 中把 fileExists 显式置为 false。这是从 Android 版本迁移过来时最容易踩的一个坑——Java 的 File.exists() 是返回布尔值,语义不同。
5.1.6 生成 URI 并交付给系统
private async startViewData(filePath: string, result: MethodResult): Promise<void> {
const context: common.UIAbilityContext | null = this.abilityContext;
if (context == null) {
result.success(this.createResult('error', 'Ability is not available'));
return;
}
try {
// 生成 file:// URI,等价于 Android 的 FileProvider.getUriForFile
const uri: string = fileUri.getUriFromPath(filePath);
const mimeType: string = this.mimeTypeOf(filePath);
const want: Want = {
action: ACTION_VIEW_DATA,
uri: uri,
type: mimeType,
// 把该 URI 的读权限临时授予目标应用,对齐 Android 的 FLAG_GRANT_READ_URI_PERMISSION
flags: wantConstant.Flags.FLAG_AUTH_READ_URI_PERMISSION,
};
await context.startAbility(want);
hilog.info(DOMAIN, TAG, 'startAbility succeeded for [%{public}s]', uri);
result.success(this.createResult('done', 'File opened successfully'));
} catch (err) {
const error = err as BusinessError;
hilog.error(DOMAIN, TAG, 'startAbility failed, code: %{public}d, message: %{public}s',
error.code, error.message);
if (error.code == ERR_ABILITY_NOT_FOUND) {
// 系统里没有能处理该 MIME 类型的应用,对应 Android 的 ActivityNotFoundException
result.success(this.createResult('noAppToOpen', 'No app found to open this file type'));
} else {
result.success(this.createResult('error',
error.message != null && error.message.length > 0 ? error.message : 'Unknown error occurred'));
}
}
}
这里是整个适配最需要讲清楚的一处差异:
| 维度 | Android | OpenHarmony |
|---|---|---|
| URI 生成 | FileProvider.getUriForFile → content:// | fileUri.getUriFromPath → file:// |
| 授权方式 | FLAG_GRANT_READ_URI_PERMISSION | wantConstant.Flags.FLAG_AUTH_READ_URI_PERMISSION |
| 拉起方式 | Intent.ACTION_VIEW | action: 'ohos.want.action.viewData' |
| 无匹配应用 | ActivityNotFoundException | startAbility 抛 16000001 |
| 需要 Provider 声明 | 是(AndroidManifest.xml + file_paths.xml) | 否 |
Android 自 API 24 起禁止把 file:// 直接放进 Intent,必须走 FileProvider 换成 content://,否则会抛 FileUriExposedException。OpenHarmony 没有这条限制,插件直接给出 file:// 形式的应用沙箱 URI,再靠授权旗标把读权限临时借给目标应用:
file://com.scanpay.document_opener_example/data/storage/el2/base/haps/entry/cache/doc_opener_demoFQPOHV/sample_report.pdf
这条 URI 来自第八节抓取的真实 hilog。可以看到它包含 bundleName、el2/base/haps/entry/cache 这类沙箱层级——目标应用本来无权访问,能读到完全依赖 FLAG_AUTH_READ_URI_PERMISSION。因此这个旗标不是可选项,漏掉它 startAbility 依然会「成功」,但查看器会打开失败或显示空白。
mimeTypeOf 与 Android 的 when(extension) 分支一一对应:
private mimeTypeOf(filePath: string): string {
const dotIndex: number = filePath.lastIndexOf('.');
const extension: string = dotIndex >= 0 ? filePath.substring(dotIndex + 1).toLowerCase() : '';
if (extension == 'pdf') {
return 'application/pdf';
}
if (extension == 'csv') {
return 'text/csv';
}
return '';
}
type 字段会影响系统如何挑选目标应用:application/pdf 会命中 PDF 阅读器,text/csv 会命中表格类应用。这也解释了第八节里为什么两个文件被交给了不同的系统界面。
5.2 权限:这个插件不需要任何权限声明
插件 ohos/src/main/module.json5 的完整内容只有模块声明:
{
"module": {
"name": "document_opener",
"type": "har",
"deviceTypes": [
"default",
"tablet"
]
}
}
没有 requestPermissions。原因在于两条路径都不越界:
fs.accessSync读取的是本应用沙箱内的路径,不需要权限;startAbility通过 URI 授权把访问权转交给目标应用,调用方同样不需要声明权限。
示例 entry 模块里仍然保留了脚手架自带的 ohos.permission.INTERNET:
"requestPermissions": [
{"name" : "ohos.permission.INTERNET"},
]
这个示例不联网,该权限实际未被使用。接入真实应用时按需保留或删除,它和 document_opener 的功能无关。
需要打开沙箱外的文件(例如「下载」目录)时,属于另一套授权模型:要么走系统文件选择器拿到 URI,要么按业务需要申请
ohos.permission.READ_WRITE_DOWNLOAD_DIRECTORY这类目录权限。这部分不在插件职责范围内。
5.3 注册并导出插件
pubspec.yaml 通过以下配置声明 OHOS 插件类:
flutter:
plugin:
platforms:
android:
package: com.scanpay.document_opener
pluginClass: DocumentOpenerPlugin
ios:
pluginClass: DocumentOpenerPlugin
ohos:
pluginClass: DocumentOpenerPlugin
插件的 ohos/index.ets 导出实现:
import DocumentOpenerPlugin from './src/main/ets/components/plugin/DocumentOpenerPlugin';
export default DocumentOpenerPlugin;
执行 flutter pub get 和构建后,Flutter 工具会为应用生成插件注册代码。通常不应手工编辑 GeneratedPluginRegistrant.ets,因为下次构建可能覆盖它。
注册成功时,hilog 里会出现两行(第八节有完整抓取):
Adding plugin: DocumentOpenerPlugin
DocumentOpenerPlugin: attached to engine, channel [com.scanpay.document_opener] registered
DocumentOpenerPlugin: attached to ability
注册异常的排查步骤见第九节 MissingPluginException。
5.4 检查 example 的 OHOS 应用结构
本例的 example/ohos/build-profile.json5 在 products 中声明版本与签名配置:
{
"app": {
"products": [
{
"name": "default",
"signingConfig": "default",
"compatibleSdkVersion": "6.1.1(24)",
"targetSdkVersion": "6.1.1(24)",
"runtimeOS": "HarmonyOS"
}
]
}
}
signingConfigs段落在本仓库里已经写入了本机 DevEco Studio 自动生成的证书路径与密钥口令。这部分内容不应进入公开仓库,提交前需要把signingConfigs整段移除,只保留signingConfig: "default"的引用;本机重新用 DevEco Studio 配置即可。第 6.2 节给出了检查命令,第 9.7 节说明了这个遗留问题。
配置后,在 DevEco Studio 中打开 example/ohos 执行一次 Sync Project。如果 entry 模块正常识别,Project 视图中会出现 entry,并能打开 entry 模块的签名配置。
六、补全交付文件并提交适配分支
6.1 除代码外还要补全哪些文件
代码适配完成后,还需要整理安装说明、接口文档、版本记录和开源信息:
| 文件 | 应写清楚的内容 |
|---|---|
README.OpenSource | 上游名称、源码地址、适配版本、版权及许可证;本例为 MIT、julien-kv (Scanpay) |
README.md | 原项目说明、OHOS 支持入口、配套 Demo 和文档链接;保留上游信息 |
README.OpenHarmony_CN.md | 简介、AtomGit 安装方式、版本对应关系、环境约束、权限、接口表、示例、已验证范围和遗留问题 |
README.OpenHarmony.md | 与中文说明对应的英文文档 |
CHANGELOG.OpenHarmony.md | OHOS 新增能力、适配版本、兼容限制与测试范围 |
LICENSE | 保留上游 MIT 许可证原文 |
example/README.md | 依赖方式、运行目录、签名、操作步骤与效果图 |
pubspec.yaml、ohos/oh-package.json5 | 核对包名、版本、插件注册、仓库地址、许可证和依赖 |
.gitignore | 忽略构建缓存及本机签名材料 |
CHANGELOG.OpenHarmony.md 里这一次写了四条实质变更,可以对照检查自己的适配是否漏项:
## 1.0.3-ohos-1.0.0-beta.1
- Beta official version.
- Add OpenHarmony platform support: a new `ohos` platform declaration in pubspec.yaml,
an ArkTS plugin class `DocumentOpenerPlugin` implementing the `com.scanpay.document_opener`
method channel, and a newly created `example/ohos` host project.
- Add the missing `Platform.isOhos` exclusion in the Dart platform check, otherwise the
desktop branch would be wrongly taken on OpenHarmony and `UnsupportedError` would be
thrown before reaching the method channel.
- `openDocument` is implemented with `fileUri.getUriFromPath` plus `startAbility`
(action `ohos.want.action.viewData`, `FLAG_AUTH_READ_URI_PERMISSION`), and reports
`noAppToOpen` on error code `16000001`, matching the Android `ActivityNotFoundException` branch.
- The public Dart API is unchanged.
注意
ohos/oh-package.json5里的license字段写的是Apache-2.0,而仓库LICENSE与README.OpenHarmony_CN.md都写 MIT。这是当前仓库的一处不一致,接入时以LICENSE文件的 MIT 为准,并建议把oh-package.json5的字段一并改成MIT(见第 9.7 节)。
6.2 提交前检查
提交前先完成第八节的插件、example 和真机测试,再从根目录检查改动:
git branch --show-current
git diff --check
git status --short
git diff --stat
git diff
重点检查两件事:diff 里有没有本机路径与签名信息,文档里的版本和分支是否与提交内容一致。签名材料可以用下面的命令确认是否误入库:
git log -p --follow -- example/ohos/build-profile.json5 | grep -nE "keyPassword|storePassword|certpath"
有输出就说明签名口令已经进入历史记录,需要单独处理(重写历史或更换证书口令),仅删文件不足以消除泄露。
6.3 提交并推送到 AtomGit
文档和代码整理完成后,在根目录暂存并提交:
git add ohos pubspec.yaml lib example .gitignore
git add README.OpenHarmony_CN.md README.OpenHarmony.md
git add README.OpenSource CHANGELOG.OpenHarmony.md
git diff --cached --check
git diff --cached --stat
git commit -m "feat: 完成 document_opener 插件 OpenHarmony 平台适配"
git remote -v
git branch --show-current
git push -u origin main
本次提交 52b5c92 的统计是 52 files changed, 1725 insertions(+), 2 deletions(-)——其中真正的业务代码只有 lib/document_opener.dart(2 行改动)和 DocumentOpenerPlugin.ets(243 行新增),其余都是脚手架、示例工程与文档。
推送后在 AtomGit 发起合并请求,说明上游来源和版本、OHOS 实现范围、依赖及权限、测试环境、操作结果、已知限制,并附 Demo 运行图。
七、使用根目录 example 演示接入
仓库自带 example/,可以直接用来调试插件和体验四种打开场景。
7.1 本地适配时使用路径依赖
当前 example/pubspec.yaml 的依赖是:
dependencies:
flutter:
sdk: flutter
document_opener:
path: ../
cupertino_icons: ^1.0.8
../ 相对于 example/pubspec.yaml 指向插件根目录,修改根目录插件后可直接联调。
7.2 通过 AtomGit 引入插件
业务应用通过 AtomGit 引入时,将 path 配置替换为 Git 依赖。这里固定到本文使用的提交:
dependencies:
document_opener:
git:
url: https://atomgit.com/oh-flutter/document_opener.git
ref: 52b5c929e5709441836bbd43af93b9e7e0b8d135
使用自己的适配版本时,先推送分支或 TAG,再把 ref 改为 feat/ohos_document_opener_1.0.3 或 1.0.3-ohos-1.0.0-beta.1。
README.OpenHarmony_CN.md里给的地址是https://atomgit.com/CPF-Flutter/fluttertpc_document_opener,与本文仓库(oh-flutter/document_opener)不一致——组织名和仓库名都不同。按 README 复制会拉不到代码,详见第 9.7 节。
从插件根目录执行:
cd example
flutter pub get
flutter pub deps
检查 example/pubspec.lock 中 document_opener 的来源为 git(或 path),并核对 url、ref 和 resolved-ref。
7.3 示例页面做了什么
example/lib/main.dart(352 行)并没有让用户手选文件,而是在 initState 里现场造三个样例文件:
void _prepareSampleFiles() {
final Directory dir = Directory.systemTemp.createTempSync('doc_opener_demo');
_sampleDir = dir.path;
_pdfPath = '${dir.path}/sample_report.pdf';
_csvPath = '${dir.path}/sample_table.csv';
_txtPath = '${dir.path}/sample_notes.txt';
File(_pdfPath).writeAsStringSync('%PDF-1.4\n% demo pdf placeholder\n');
File(_csvPath).writeAsStringSync('name,age\nAlice,30\nBob,25\n');
File(_txtPath).writeAsStringSync('unsupported file type');
}
用 Directory.systemTemp 的好处是:文件天然落在应用沙箱内(真机上是 el2/base/haps/entry/cache),因此不需要任何存储权限就能读;也正因为走通了,才能验证「沙箱文件 + 授权旗标」这条路径。
四个按钮分别覆盖四种结果:
| 按钮 | 目标文件 | 预期结果 | 拦截位置 |
|---|---|---|---|
| Open PDF | sample_report.pdf | done | 无拦截,交给系统预览 |
| Open CSV | sample_table.csv | done | 无拦截,交给系统表格视图 |
| Unsupported .txt | sample_notes.txt | error:「Only PDF and CSV files are supported」 | Dart 层 _isValidFileType |
| Missing file | not_exists.pdf | error:「File does not exist」 | ArkTS 层 fs.accessSync |
这张表是本文验证部分的核心:四个按钮刻意让失败发生在不同层,从而能分别验证 Dart 侧拦截和原生侧拦截是否都按预期工作。
7.4 结果展示与状态说明
每次调用后,DocumentOpenResult 会被塞进一个本地列表,界面按「图标 + 标签 + type」展示;点击某条记录会弹出对话框,显示完整的 message。页面底部还固定列出 DocumentResultType 四个取值的含义。
这种设计对适配调试很友好:失败时不用连调试器,直接从 message 就能判断是哪一层返回的。
八、验证、构建与鸿蒙设备运行效果
8.1 分别验证插件与 example
从插件仓库根目录执行:
flutter pub get
flutter analyze
cd example
flutter pub get
flutter analyze
本次执行结果:
Analyzing document_opener...
No issues found! (ran in 5.5s)
Analyzing example...
No issues found! (ran in 5.1s)
flutter test 在本仓库无法执行,因为上游没有 test/ 目录:
$ flutter test
Test directory "test" not found.
这是上游的现状,不是适配引入的问题。适配时没有补充测试,也没有把 flutter test 写成「通过」。要补测试,可以先建 test/document_opener_test.dart,用 TestDefaultBinaryMessengerBinding 打桩通道,覆盖四个按钮对应的四条分支。
Dart 测试覆盖接口与解析逻辑,文件打开、URI 授权和系统查看器行为仍必须在鸿蒙设备上验证。
8.2 确认设备连接
hdc list targets
flutter devices
本次输出:
4UQ9K25508013016
设备首次连接电脑时,需要在手机端确认调试授权。列表为空时,检查 USB 连接、调试模式和电脑授权。
8.3 配置签名
真机安装的 HAP 通常需要有效签名。推荐使用 DevEco Studio 为 entry 模块配置自动签名:
- 用 DevEco Studio 打开
example/ohos,不是仓库根目录; - 等待工程 Sync 成功,确认 Project 视图中存在
entry模块; - 打开 File > Project Structure > Signing Configs;
- 为
defaultproduct 选择或生成签名; - 确认设备、应用包名(
com.scanpay.document_opener_example)、证书和 Profile 匹配; - 再回到终端执行构建或安装。
签名材料保存在本机,公开仓库中只保留构建所需的通用配置。
8.4 构建与安装
本次直接用 DevEco 自带的 hvigor 构建(Windows 下需把 JBR 加进 PATH,否则打包阶段找不到 java):
cd example/ohos
export JAVA_HOME="/d/Program Files/Huawei/DevEco Studio/jbr"
export PATH="/d/Program Files/Huawei/DevEco Studio/jbr/bin:$PATH"
"/d/Program Files/Huawei/DevEco Studio/tools/hvigor/bin/hvigorw" \
assembleHap -p product=default -p buildMode=debug --no-daemon
产物位于:
example/ohos/entry/build/default/outputs/default/
本次执行结果为 BUILD SUCCESSFUL in 19 s 399 ms(42 tasks:20 executed、22 up-to-date)。目录中同时有 entry-default-signed.hap 与 entry-default-unsigned.hap。本次产出文件约 145,042,523 字节(约 138 MB),体积主要来自 debug 版 Flutter 引擎,与插件代码无关。
安装并启动:
hdc install -r entry-default-signed.hap
hdc shell aa start -a EntryAbility -b com.scanpay.document_opener_example
8.5 在设备上测试四种打开场景
按下面的顺序操作,即可复现第八节的截图:
- 打开应用,确认首屏
Platform显示ohos、Sample directory显示created、Records为0; - 点击 Open CSV,系统弹出「文件预览服务」隐私声明,同意后表格内容打开;
- 返回应用,记录区出现一条
CSV/type: done; - 点击 Open PDF,系统文件预览打开
sample_report.pdf; - 点击 Unsupported .txt,记录区出现
TXT/type: error; - 点击 Missing file,记录区出现
Missing/type: error; - 点开
Missing记录,对话框显示message: File does not exist; - 点开
TXT记录,对话框显示message: Only PDF and CSV files are supported。
8.6 鸿蒙设备运行效果
首屏:Platform 一栏显示 ohos,这是 Platform.operatingSystem 在鸿蒙上的返回值。它能正常显示,本身就说明该应用跑在 OHOS 上并进入了插件链路——若沿用上游代码,点击按钮会在 Dart 层直接抛 UnsupportedError。
CSV 打开:首次调用会拉起系统「文件预览服务」并弹出隐私声明。这张提示框本身就是 startAbility 生效的证据——说明 URI 已被系统接受,且有应用响应 text/csv。同意后,sample_table.csv 在表格视图中打开,能看到 name/age 两列与 Alice 30、Bob 25 两行真实内容。
PDF 打开:sample_report.pdf 交给了系统文件预览。注意截图里显示的是文件卡片和 32 B 的体积,而不是 PDF 页面——因为示例写入的是 %PDF-1.4\n% demo pdf placeholder\n 这段占位文本,不是合法的 PDF 文件,预览器无法渲染页面。
这一点必须如实说明:本次验证证明了「URI 生成、授权、startAbility 移交」这条链路通畅,但没有验证真实 PDF 的渲染效果。要验证后者,把 _prepareSampleFiles 里写入的内容换成一个真实 PDF 的字节即可——插件侧代码不需要任何改动。
四条记录汇总:四个按钮各产生一条记录,PDF、CSV 为绿色 done,TXT、Missing 为红色 error。
失败消息:点开记录可以看到完整 message。Missing 的 File does not exist 来自 ArkTS 侧的 fs.accessSync;TXT 的 Only PDF and CSV files are supported 来自 Dart 侧——注意这两句话在 Dart 和 ArkTS 里各写了一份,文案完全相同,所以单看消息无法判断来源,需要配合下面的 hilog 证据。
结果类型说明:页面底部固定列出四个枚举取值的含义,其中 permissionDenied 在当前实现中不会出现(见 4.4 节)。
图 10:DocumentResultType 四个取值的业务含义。
8.7 用 hilog 区分「哪一层拦下的」
只看界面无法判断失败发生在 Dart 还是 ArkTS。把日志级别调到 Debug 后抓取插件日志,三条链路就分得很清楚:
hdc shell hilog -b D
hdc shell hilog | grep DocumentOpenerPlugin
插件注册与一次成功调用:
Adding plugin: DocumentOpenerPlugin
DocumentOpenerPlugin: attached to engine, channel [com.scanpay.document_opener] registered
DocumentOpenerPlugin: attached to ability
DocumentOpenerPlugin: onMethodCall: openDocument
DocumentOpenerPlugin: startAbility succeeded for [file://com.scanpay.document_opener_example/data/storage/el2/base/haps/entry/cache/doc_opener_demoFQPOHV/sample_report.pdf]
点击 TXT 时:日志里没有任何 onMethodCall。说明 Dart 层在 _isValidFileType 就已经返回,请求根本没进入通道。
点击 Missing file 时:有 onMethodCall: openDocument,但没有 startAbility succeeded。说明请求进了原生层,被 fs.accessSync 拦下并返回 File does not exist。
三种情况合起来构成一张分层验证表:
| 场景 | onMethodCall | startAbility succeeded | 结论 |
|---|---|---|---|
| Open PDF / CSV | 有 | 有 | 全链路走通,已移交系统 |
| Unsupported .txt | 无 | 无 | Dart 层拦截,未过通道 |
| Missing file | 有 | 无 | 原生层拦截,未发起调用 |
这张表比界面截图更有说服力:它证明了「Dart 校验」和「原生校验」是两道独立的关卡,而不是重复判断。
抓日志时注意:
hilog启动会先回放一段时间的历史缓冲区。如果不清空就统计条数,会把上一个进程的日志算进来,得出「TXT 也调用了通道」的错误结论。本文的做法是先aa force-stop重启应用,再按时间戳和进程号筛选。
8.8 未在真机触发的分支
noAppToOpen 分支本次没有被触发。它只在 startAbility 抛 16000001(目标 ability 不存在)时返回,而当前设备装有系统文件预览服务,application/pdf 与 text/csv 都有应用响应。要验证这一分支,需要在一台卸载了所有 PDF/表格查看器的设备上测试,或临时把 mimeTypeOf 改成一个无人处理的类型(例如 application/x-unknown)制造「无匹配应用」的状态。
permissionDenied 同样没有被触发,原因见 4.4 节:没有原生实现会产生它。
九、FAQ:适配过程与使用问题
9.1 点击按钮直接抛 UnsupportedError: Unsupported platform
这是本次适配遇到的核心问题,也是所有「按平台二分」的插件在鸿蒙上最容易踩的坑。
现象:Dart 侧抛出 UnsupportedError,hilog 里找不到任何插件日志,onMethodCall 一次都没有。
原因:上游判断写成 if (!Platform.isIOS && !Platform.isAndroid),把「非 iOS 非 Android」等价于「桌面平台」。OpenHarmony 落进桌面分支后,三个 else if(macOS/Windows/Linux)都不匹配,走到最后的 throw UnsupportedError("Unsupported platform")。
处理:在条件里补上 && !Platform.isOhos,让 OHOS 与 iOS/Android 一起走方法通道(见 4.2 节)。
排查这类问题的通用手法:如果异常在 Dart 层抛出,通道日志一定为空。反过来,只要 hilog 里有
onMethodCall,就说明问题在原生侧。
9.2 MissingPluginException
这通常表示 Dart 通道找不到已注册的原生插件。新增原生插件后需要重新构建应用:
cd example
flutter clean
flutter pub get
flutter run -d <device-id>
如果仍然出现,检查自动生成的插件注册文件中是否包含 DocumentOpenerPlugin,同时核对 pubspec.yaml 的 ohos.pluginClass、ohos/index.ets 的导出和 ohos/oh-package.json5 的 main 字段。三者名字必须一致。
另外注意:pubspec.yaml 里 ohos 节点的缩进必须在 platforms 下与 android、ios 同级。缩进错一层,Flutter 工具就识别不到 OHOS 平台。
9.3 返回 done 但查看器打不开或显示空白
done 只代表 startAbility 没抛异常,不代表目标应用能读到文件。按以下顺序检查:
want.flags是否包含wantConstant.Flags.FLAG_AUTH_READ_URI_PERMISSION。少了它,URI 能传过去但读不到,表现是查看器空白或提示文件损坏;uri是否由fileUri.getUriFromPath生成,而不是手工拼接字符串;type是否与实际扩展名匹配。type为空或与内容不符时,系统可能选中错误的查看器;- 文件是否真的在应用沙箱内。沙箱外路径即使生成了 URI,授权也覆盖不到。
9.4 想打开「下载」目录里的文件
本插件按设计只处理沙箱内文件。打开沙箱外文件属于另一套授权模型:
- 用系统文件选择器(
@ohos.file.picker)让用户主动选文件,拿到临时授权的 URI,再把这个 URI 交给查看器; - 或者按业务需要申请
ohos.permission.READ_WRITE_DOWNLOAD_DIRECTORY这类目录权限,并在module.json5中声明reason与usedScene。
这两条路都会改动 Dart 层 API 语义(从「传路径」变成「传 URI」),不建议在适配层硬塞,最好作为独立能力评估。
9.5 系统里没有能打开 PDF 的应用
startAbility 会抛 16000001,插件捕获后返回 noAppToOpen。业务侧应把这个类型单独处理,提示用户「请先安装 PDF 阅读器」,而不是笼统地显示「打开失败」。
如果想在打开前就探测可用性,可以用 @ohos.bundle.bundleManager 的 queryAbilityInfo 按 want 查询匹配应用,但要注意这会增加一次跨进程查询,且查询结果与真正拉起之间仍存在竞争,不能替代对 noAppToOpen 的处理。
9.6 fs.accessSync 的行为与 Java 不同
Android 的 File.exists() 返回布尔值,OpenHarmony 的 fs.accessSync 在文件不存在时抛异常。直接写成 if (!fs.accessSync(path)) 会出现两种问题:文件不存在时异常向外冒泡,或者文件存在时返回值语义被误读。
正确写法是包在 try/catch 中,并在 catch 里显式置为 false(见 5.1.5 节)。
9.7 当前仓库的已知遗留问题
以下几项是本次适配后仍然存在的状态,如实列出:
README.OpenHarmony_CN.md的安装地址错误:写的是https://atomgit.com/CPF-Flutter/fluttertpc_document_opener,实际仓库为oh-flutter/document_opener,组织名与仓库名均不一致。- TAG 版本表的「分支」列与实际不符:表中写
br_v1.0.3_ohos,而适配提交实际在main上,仓库中也没有该分支。 - 签名材料已入库:
example/ohos/build-profile.json5包含本机certpath、keyPassword、storePassword等字段,已随52b5c92进入版本历史。这是需要优先处理的安全问题。 - 许可证字段不一致:
ohos/oh-package.json5写Apache-2.0,仓库LICENSE与中文 README 写 MIT。 - 上游没有
test/目录:flutter test无法执行,本次未补充测试。 permissionDenied无产生方:Dart 层定义并解析,但 Android 与 OHOS 实现都不会返回它。noAppToOpen未在真机验证:当前设备有系统预览服务,无法构造16000001。- PDF 只验证了移交,未验证渲染:示例写入的是占位文本而非合法 PDF。
- 设备类型声明不一致:插件
module.json5为["default", "tablet"],示例 entry 为["phone"],不影响运行但建议统一。 compatibleSdkVersion与targetSdkVersion都固定为6.1.1(24):这意味着 API 低于 24 的设备无法安装。要覆盖更老的设备,需要下调compatibleSdkVersion并在低版本设备上重新验证fileUri与FLAG_AUTH_READ_URI_PERMISSION的可用性。
9.8 改了本地 ArkTS,Demo 为什么没变化
先检查 example/pubspec.yaml:如果用的是 Git 依赖,Flutter 读取的是远程提交,不会自动读取本地插件改动。本地联调切回 path: ../;测试远程版本则先提交推送,再更新依赖并核对 pubspec.lock 的 resolved-ref。
原生代码(.ets)变动后必须停止应用并重新构建安装,Dart 热重载不会重新编译 ArkTS。
9.9 编译成功但安装失败
常见原因包括:
- HAP 未签名或使用了错误的 Profile;
- 设备未加入调试设备列表;
- 包名
com.scanpay.document_opener_example与签名 Profile 不匹配; - 安装包的
compatibleSdkVersion高于设备 API(本例是 24,设备也是 24,刚好满足); - 手机上已经安装了使用不同证书签名的同包名应用。
根据安装错误码区分签名、版本和包名冲突,再处理对应配置。
相关链接
更多推荐



所有评论(0)