HarmonyOS技术精讲-应用间跳转:典型场景三——文件打开与协作编辑

在这里插入图片描述

这个场景到底在解决什么问题

HarmonyOS 应用间跳转的能力,官方文档讲了不少 API,但落到实际开发中,最容易出问题的是“以文件为中心”的跳转场景。

什么叫以文件为中心?就是你本地有个文件,你想把它丢给其他应用打开。比如:

  • 一个 PDF 文档 → 希望系统拉起 PDF 阅读器
  • 一个 JPEG 图片 → 希望打开图片编辑器
  • 一个 .docx 文件 → 希望 WPS 或类似应用处理

原理上,就是通过 MIME 类型匹配,让系统找到对应应用列表,然后用户选择。但这件事在 HarmonyOS 里有个关键门槛:文件路径必须通过 URI 传递,不能用本地绝对路径。这一点和 Android 原生开发差异很大,很多人第一轮都会在这里卡住。

环境说明

DevEco Studio 版本:DevEco Studio 6.1.0 及以上
HarmonyOS SDK 版本:HarmonyOS 6.1.0(23) 及以上
目标设备:手机

核心实现:文件管理器界面 + 跳转逻辑

下面完整实现一个文件管理器界面,点击文件列表中的任意一项,自动向系统发起“打开这个文件”的请求。

步骤1:获取文件列表

文件来源用 FilePicker 中的 DocumentViewPicker 获取,用户选择文件后拿到一个临时 URI。

// pages/FilePickerPage.ets
import { picker } from '@kit.CoreFileKit';

@Entry
@Component
struct FilePickerPage {
  @State fileList: string[] = [];
  @State fileUris: string[] = [];
  private documentPicker = new picker.DocumentViewPicker();

  build() {
    Column() {
      Button('选择文件')
        .onClick(() => {
          this.pickFiles();
        })
      List() {
        ForEach(this.fileList, (fileName: string, index: number) => {
          ListItem() {
            Text(fileName)
              .onClick(() => {
                this.openFileWithDefaultApp(index);
              })
          }
        })
      }
    }
  }

  async pickFiles() {
    try {
      let result = await this.documentPicker.select();
      if (result && result.length > 0) {
        // result 返回的是文件 URI 列表,形式为临时路径
        result.forEach((uri: string) => {
          // 从 URI 中提取文件名用于显示
          let parts = uri.split('/');
          let name = parts[parts.length - 1];
          this.fileUris.push(uri);
          this.fileList.push(name);
        });
      }
    } catch (err) {
      console.error('文件选择失败: ' + JSON.stringify(err));
    }
  }
}

注意:

  • DocumentViewPicker.select() 返回的是临时 URI,不是沙箱内文件路径
  • 这个 URI 只在当前应用生命周期内有效,不能持久化存储
  • 文件名提取只做了一个简单分割,实际项目中建议用 fileIo.stat() 获取更准确的文件信息

步骤2:根据文件后缀获取 MIME 类型

MIME 类型是系统选择应用的依据。我们需要一个工具函数把文件后缀映射为标准 MIME。

// utils/MimeUtils.ets
export function getMimeTypeByExtension(uri: string): string {
  let ext = uri.split('.').pop()?.toLowerCase();
  switch (ext) {
    case 'pdf':
      return 'application/pdf';
    case 'jpg':
    case 'jpeg':
      return 'image/jpeg';
    case 'png':
      return 'image/png';
    case 'gif':
      return 'image/gif';
    case 'doc':
    case 'docx':
      return 'application/msword';
    case 'xls':
    case 'xlsx':
      return 'application/vnd.ms-excel';
    case 'ppt':
    case 'pptx':
      return 'application/vnd.ms-powerpoint';
    case 'txt':
      return 'text/plain';
    case 'mp4':
      return 'video/mp4';
    case 'mp3':
      return 'audio/mpeg';
    default:
      return 'application/octet-stream';
  }
}

为什么这样写更稳定?

直接使用 Want.type 字段,如果类型设置不准确,系统可能匹配不到任何应用。不如自己维护一个明确的映射关系,虽然麻烦一点,但能保证关键类型的准确性。

步骤3:发起应用间跳转

openLinkstartAbility 都可以,但 openLink 的封装更清晰,适合这种“打开文件”的场景。

// 在 FilePickerPage.ets 中补充 openFileWithDefaultApp 方法
import { common, Want } from '@kit.AbilityKit';
import { getMimeTypeByExtension } from '../utils/MimeUtils';

async openFileWithDefaultApp(index: number) {
  let uri = this.fileUris[index];
  if (!uri) return;

  let mimeType = getMimeTypeByExtension(uri);
  try {
    let context = getContext(this) as common.UIAbilityContext;

    // 构建 Want 对象
    let want: Want = {
      type: mimeType,
      uri: uri,
      parameters: {
        // 这个参数用于告诉目标应用如何打开文件
        'ohos.extra.param.key.openFile': true
      }
    };

    // 发起跳转
    await context.startAbility(want);
  } catch (err) {
    console.error('跳转失败: ' + JSON.stringify(err));
    // 常见失败原因:没有应用可以处理这个类型
    // 建议提示用户安装对应应用
  }
}

这里有个很重要的事情:

官方文档强调要用 openLink,但 openLink 实际上是对 startAbility 的封装。如果目标应用没有声明能够处理这个 MIME 类型,两种方式都会抛出错误。区别在于 openLink 可以针对特定 link 模式进行跳转,而文件打开场景更依赖 typeuri 的组合。

实际项目中建议直接使用 startAbility,这样控制粒度更细,错误处理也更直接。

步骤4:处理目标应用关闭后的回退

文件打开成功后,用户编辑完会回到你的应用。但有个常见问题:目标应用关闭后,你的页面栈可能被重置。 这涉及到 Ability 的生命周期管理,不是单纯的 startAbility 能解决的。

// 在 FilePickerPage 页面中监听 onNewWant 回调
// 这个回调在目标应用返回时触发
onNewWant(want: Want) {
  // 如果 want 中有返回的数据,可以在这里处理
  // 比如目标应用保存了新文件,返回了新的 URI
  if (want.parameters && want.parameters['returnUri']) {
    let returnUri = want.parameters['returnUri'] as string;
    console.info('收到返回文件URI: ' + returnUri);
    // 刷新文件列表
  }
}

注意: onNewWant 只在 Ability 的 singleton 启动模式下生效。如果你的 Ability 模式是 standard,每次跳转都会创建新页面,不会触发 onNewWant。这个区别官方文档写得很隐晦,实际测试才能发现。

常见问题与踩坑记录

坑1:临时 URI 在设备重启后失效

现象: 拿到 URI 后,如果设备重启,再试图用这个 URI 打开文件会报错 “file not found”。

原因: DocumentViewPicker.select() 返回的是临时授权 URI,系统在重启后会撤销所有临时授权。这个是 HarmonyOS 安全机制的一部分,和 Android 的 content:// URI 类似,但 HarmonyOS 的临时授权有效期更短。

解法:

有两种方案:

  1. 不持久化 URI,每次需要打开文件都让用户重新选择
  2. 如果需要持久化,建议在拿到 URI 后立即复制到应用的沙箱目录
import { fileIo } from '@kit.CoreFileKit';

async copyToSandbox(uri: string): Promise<string> {
  let context = getContext(this);
  let sandboxPath = context.filesDir + '/temp/' + Date.now() + '.pdf';
  
  try {
    // 创建输入输出流
    let input = fileIo.createStreamSync(uri, 'r');
    let output = fileIo.createStreamSync(sandboxPath, 'w');
    
    // 读取并写入
    let buffer = new ArrayBuffer(1024 * 1024);
    let readLen = await input.read(buffer);
    while (readLen > 0) {
      await output.write(buffer);
      readLen = await input.read(buffer);
    }
    
    // 关闭流
    input.closeSync();
    output.closeSync();
    
    return sandboxPath;
  } catch (err) {
    console.error('文件复制失败: ' + JSON.stringify(err));
    return '';
  }
}

这个方案的问题是:大文件复制很慢,而且会占用沙箱空间。建议只在文件小于 50MB 时使用。

坑2:目标应用关闭后,当前页面被销毁

现象: 点击文件跳转到 PDF 阅读器,PDF 阅读器关闭后,回到自己的应用发现页面状态全部丢失,回到了首页。

原因: 当你的 Ability 模式是 standard 时,startAbility 发起跳转后,当前 Ability 会被销毁。这不是 bug,是 HarmonyOS 的默认行为。很多人第一次遇到时会以为是程序崩溃了。

解法:

module.json5 中把当前页面的 Ability 模式改为 singleton

{
  "abilities": [
    {
      "name": "EntryAbility",
      "launchType": "singleton"
    }
  ]
}

改为 singleton 后,页面会保留在后台,目标应用关闭后恢复。但这也意味着单例模式下,你需要手动处理页面间的数据传递,因为不会重复创建。

坑3:MIME 类型匹配不到应用

现象: 跳转时提示 “no ability found”。

原因: 系统根据 Want.typeWant.uri 的 scheme 来匹配应用。如果类型写错了(比如把 image/jpeg 写成 image/jpg),或者设备上没有能处理这种类型的应用,就会失败。

解法:

  1. 不要自己猜 MIME 类型,用标准映射
  2. 跳转前检查是否有应用能处理
import { bundleManager } from '@kit.AbilityKit';

async canHandleType(mimeType: string): Promise<boolean> {
  try {
    let want: Want = {
      type: mimeType,
      action: 'ohos.want.action.viewData'
    };
    let abilities = await bundleManager.queryAbilityByWant(want);
    return abilities.length > 0;
  } catch (err) {
    return false;
  }
}

这个检查可以避免跳转失败时的系统弹窗,用户体验更好。

最佳实践

  1. 不要在 ForEach 中直接使用 index 作为 key

    ForEach 是 ArkUI 的动态列表组件,如果用 index 作为 key,当列表变化(比如新增文件)时,会导致所有列表项重新渲染。建议使用文件 URI 的 hash 值作为 key。

    ForEach(this.fileUris, (uri: string, index: number) => {
      ListItem() {
        Text(this.fileList[index])
      }
    }, (uri: string) => uri) // 用 URI 本身作为 key
    
  2. 跳转前做好权限检查

    临时 URI 可能在某些设备上权限不足,跳转前最好检查一下文件是否可读。

    try {
      let stat = fileIo.statSync(uri);
      if (stat.size === 0) {
        console.error('文件为空');
        return;
      }
    } catch (err) {
      console.error('文件不可读: ' + JSON.stringify(err));
      return;
    }
    
  3. 处理用户取消选择的情况

    DocumentViewPicker.select() 如果用户取消了选择,会返回空数组。很多人只处理了异常,忽略了正常取消的情况。

    let result = await this.documentPicker.select();
    if (!result || result.length === 0) {
      console.info('用户取消了选择');
      return;
    }
    

完整项目代码结构

entry/src/main/ets/
├── pages/
│   └── FilePickerPage.ets       // 主页面,文件选择和跳转
├── utils/
│   └── MimeUtils.ets            // MIME 类型工具
└── entryability/
    └── EntryAbility.ets          // Ability 配置

示例代码地址:项目地址

FAQ

Q:为什么真机测试正常,模拟器上点了跳转没反应?

A:模拟器没有预装第三方应用,而 startAbility 找不到对应应用时会抛异常。建议真机测试,或者先在模拟器上装一个能处理对应类型的应用。

Q:临时 URI 可以保存到数据库吗?

A:技术上可以保存字符串,但重启后一定会失效。建议只用于单次会话,或者复制文件到沙箱后用沙箱路径。

Q:为什么有的 PDF 能看到,有的打不开?

A:临时 URI 的访问权限可能受文件来源影响。如果文件来自云盘、微信等第三方应用,可能只获得了部分读取权限。建议在 DocumentViewPicker.select() 时就检查权限是否完整。

Q:跳转后如何接收目标应用返回的数据?

A:用 onNewWant 回调接收,但前提是 Ability 模式是 singletonstandard 模式下目标应用返回后会重新创建页面,不会触发任何回调。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐