uni-app 里把接口文件流存到手机、再拉起系统分享

插件:lf-file-share 1.3.0
地址:https://ext.dcloud.net.cn/plugin?id=26490

业务场景:后端导出 Excel / PDF(二进制文件流),前端拿到 ArrayBuffer,用户保存到文件管理,或通过系统面板分享到微信、邮件等。H5、Android、iOS、鸿蒙实现不同。本文说明分流方式、关键代码和真机问题。


需求

两类交互:

  • 保存:Android 写入公共 Download 目录,可在系统「下载」中查看;不写入 Android/data/包名 私有目录(除非显式指定沙盒)
  • 分享:调起系统分享面板,将文件交给其他 App

两个主方法:

downloadOrShareArrayBuffer(buffer, filename, { appAction: 'save' | 'share', mime, ... })
downloadOrShareByUrl(url, { appAction, filename, mime, ... })

appAction 不传时默认 sharesave 只写入本地并返回路径,不调起分享。


平台差异

环境 行为
H5 Blob 触发浏览器下载,无系统分享
Android 10+ 分区存储;公共目录使用 MediaStore / DownloadManager
Android 分享 私有路径其他 App 无法读取,必须使用 FileProvider
iOS 写入 App 沙盒;分享使用 UIActivityViewController
鸿蒙 不命中 APP-PLUS,不使用 plus API
nvue 无 DOM,不能依赖 document 监听 plusready
大文件 需处理 Base64 空串、禁止 JS 逐字节写 Java、写入后校验文件大小

处理流程:

ArrayBuffer / URL
      │
      ├─ H5 ──────────── Blob 下载
      ├─ APP-PLUS ────── Android / iOS(plus)
      ├─ APP-HARMONY ─── 鸿蒙(getFileSystemManager)
      └─ uni-app x ───── utssdk/

条件编译

鸿蒙命中 APP / APP-HARMONY,不命中 APP-PLUS。仅编写 APP-PLUS 分支时,鸿蒙端无实现。

// #ifdef H5
downloadFileForH5(buffer, name, mime)
// #endif

// #ifdef APP-HARMONY
return await downloadOrShareArrayBufferHarmony(...)
// #endif

// #ifdef APP-PLUS
await waitPlusReady()
// 写入公共目录或沙盒,再按 appAction 分享
// #endif

Android save 默认路径:

/storage/emulated/0/Download/{saveDirName}/文件名

写入沙盒时传 saveLocation: 'sandbox'


接口导出表格

const res = await uni.request({
  url: 'https://api.example.com/export/excel',
  method: 'GET',
  responseType: 'arraybuffer' // 必须设置,否则拿到的不是二进制
})

await downloadOrShareArrayBuffer(res.data, '销售报表.xlsx', {
  appAction: 'save',
  mime: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
  saveDirName: 'MyApp',
  debug: true
})

无后端时使用内置 mock,生成可打开的最小 xlsx:

import {
  mockFetchExcelExport,
  downloadOrShareArrayBuffer
} from '@/uni_modules/lf-file-share'

const res = await mockFetchExcelExport({ filename: '销售报表.xlsx' })
await downloadOrShareArrayBuffer(res.data, res.filename, {
  mime: res.mime,
  appAction: 'share',
  chooserTitle: '分享表格'
})

大文件 / 大图 mock:

await mockFetchLargeFile()   // 默认 2.5MB
await mockFetchLargeImage()  // 默认 2.6MB BMP

关键代码

ArrayBuffer 转 Base64

App 写文件需要 Base64。uni.arrayBufferToBase64 在部分机型或大文件场景会返回空串,或解码后字节数与 buffer.byteLength 不一致。处理顺序:

  1. 使用 uni.arrayBufferToBase64
  2. 校验 Base64 反推长度;不一致则改用 JS 分片编码
  3. 写入时按 4 的倍数分片 decode(Base64 分组要求)
const getArrayBufferBase64 = (buffer) => {
  const expectSize = buffer.byteLength || 0
  let base64 = cleanBase64(uni.arrayBufferToBase64(buffer))

  if (base64 && getBase64ByteLength(base64) === expectSize) {
    return { base64, source: 'uni.arrayBufferToBase64', byteLength: expectSize }
  }

  base64 = cleanBase64(arrayBufferToBase64ByChunk(buffer))
  // 仍不一致则抛错,禁止写出空文件
  return { base64, source: 'chunkFallback', byteLength: expectSize }
}

禁止整包 decode,禁止 JS 循环逐字节 write 到 Java。使用分片 + BufferedOutputStream,写完后校验 file.length()

Android 10+ 写公共 Download

步骤:

  1. ContentValues 写入 DISPLAY_NAMEMIME_TYPERELATIVE_PATHIS_PENDING=1
  2. insert 得到 Uri
  3. openOutputStream 写入内容
  4. IS_PENDING 置为 0;失败则 delete 该 Uri

嵌套类使用 $ 导入,否则运行时无法读取常量:

const Downloads = plus.android.importClass('android.provider.MediaStore$Downloads')
const MediaColumns = plus.android.importClass('android.provider.MediaStore$MediaColumns')

resolver.insert is not a function

真机报错:

TypeError: resolver.insert is not a function

原因:getContentResolver() 返回对象上的 Java 方法未挂载为 JS 函数,直接调用 insert 得到 undefined。使用 plus.android.invoke 兜底:

const invokeAndroid = (target, method, ...args) => {
  if (!target) return null
  if (typeof target[method] === 'function') return target[method](...args)
  return plus.android.invoke(target, method, ...args)
}

const resolver = invokeAndroid(main, 'getContentResolver')
const uri = invokeAndroid(resolver, 'insert', collection, values)
const outputStream = invokeAndroid(resolver, 'openOutputStream', uri)

Cursor.moveToFirst、DownloadManager 轮询查询使用同一方式调用。

Android 分享

私有文件禁止直接传递 file://。使用 FileProvider 生成 content Uri:

uri = FileProvider.getUriForFile(main, pkg + '.dc.fileprovider', file)
// 失败则尝试 pkg + '.fileprovider',再失败使用 Uri.fromFile
intent.putExtra(Intent.EXTRA_STREAM, uri)
main.startActivity(Intent.createChooser(intent, chooserTitle))

分享失败时检查原生工程 FileProvider 与 AndroidManifest 配置。

iOS 分享

禁止只使用 keyWindow.rootViewController。沿 presentedViewController 找到顶层 ViewController 再 present。写入使用 plus.io + writeAsBinary(base64)

鸿蒙

实现位于 harmony.js,使用 uni.getFileSystemManager().writeFileSync(path, buffer)。图片使用 uni.shareWithSystem;其他文件使用 openDocument({ showMenu: true });两者都失败时将路径写入剪贴板并抛错。

nvue

禁止仅使用 document.addEventListener('plusready')。存在 DOM 时监听事件;无 DOM 时轮询 typeof plus

uni-app x

插件提供 utssdk/(web / android / ios / harmony)。业务侧引入方式:

import { downloadOrShareArrayBuffer } from '@/uni_modules/lf-file-share'

用法

import {
  downloadOrShareArrayBuffer,
  downloadOrShareByUrl,
  shareOnlineImage,
  shareOnlineVideo,
  shareResource
} from '@/uni_modules/lf-file-share'

// 在线 PDF
const result = await downloadOrShareByUrl('https://example.com/a.pdf', {
  filename: '说明文档',
  mime: 'application/pdf',
  appAction: 'save',
  saveDirName: 'MyApp'
})

// 图片 / 视频
await shareOnlineImage(url, { appAction: 'share' })
await shareOnlineVideo(url, {
  appAction: 'save',
  filename: 'demo.mp4',
  saveDirName: 'MyApp'
})

// buffer 或 url
await shareResource({
  buffer,
  filename: '报表.xlsx',
  appAction: 'save',
  mime: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
})

参数:

字段 说明
appAction share / save
mime 显式传入文件 MIME
saveDirName Android 公共 Download 下的子目录名
saveLocation public(默认)或 sandbox
chooserTitle 系统分享面板标题
debug 输出 Base64 转换与写入耗时

返回值示例:

{
  platform: 'Android',
  action: 'save',
  path: '/storage/emulated/0/Download/MyApp/a.xlsx',
  localPath: 'Download/MyApp/a.xlsx',
  filename: 'a.xlsx',
  size: 4024,
  isPublic: true
}

真机问题

  1. 未设置 responseType: 'arraybuffer'
    得到字符串,Excel / PDF 无法打开。

  2. 保存成功但「下载」中找不到文件
    文件写入了沙盒。Android 保存不要设置 saveLocation: 'sandbox',使用默认公共 Download。

  3. resolver.insert is not a function
    对 ContentResolver / Cursor 使用 plus.android.invoke

  4. 大文件 actual=0 或写入过慢
    禁止整包 decode,禁止逐字节 write。使用分片 + 缓冲流,写完校验长度。

  5. 分享到微信失败
    检查 FileProvider、authority、是否使用了私有 file://、mime 与文件后缀是否正确。

  6. iOS 点击分享无弹窗
    present 目标不是顶层 VC。取最上层 presentedViewController。iPad 需配置 popover 锚点。

  7. 鸿蒙引入后无效果
    实现写在 #ifdef APP-PLUS 中,鸿蒙未编译该分支。

  8. nvue 中 plus 为 undefined
    未处理无 DOM 场景,需轮询 plus 就绪。

  9. instanceof ArrayBuffer 为 false
    使用 Object.prototype.toString.call(buffer) === '[object ArrayBuffer]',并兼容 TypedArray。

  10. 文件名含 \/:*?"<>| 或超长
    写入路径失败。插件会清洗文件名与目录名,调用方也应传入合法名称。

  11. 连续点击保存
    同名文件并发写入导致大小校验失败。按钮增加 loading,禁止重复提交。

  12. 在小程序中使用本插件做系统文件分享
    小程序无对应能力,插件不支持该平台。

  13. Demo 在线 PDF 无法下载
    示例地址依赖外网。内网环境改用 mock:pdf-demo / xlsx-report

  14. Android 9 及以下写入公共目录失败
    manifest.json 配置 WRITE_EXTERNAL_STORAGE。Android 10+ 使用 MediaStore 写入公共 Download,不依赖该权限。


排查

开启 debug: true

await downloadOrShareArrayBuffer(buffer, filename, {
  appAction: 'save',
  mime: 'application/pdf',
  debug: true
})

日志输出 Base64 来源与写入耗时。source=chunkFallback 表示 uni.arrayBufferToBase64 校验失败,已改用分片编码。

console.log(buffer.byteLength, result.size, result.path)

App 打开文件:

// #ifdef APP-PLUS
plus.runtime.openFile(result.path, {}, () => {
  uni.showToast({ title: '未找到可打开此文件的应用', icon: 'none' })
})
// #endif

源码目录

uni_modules/lf-file-share/
├── index.js       # H5 / APP-PLUS 主逻辑
├── harmony.js     # 鸿蒙
├── mock.js        # 本地 mock
├── utssdk/        # uni-app x
├── readme.md
├── changelog.md
└── article.md

演示页:pages/file-share/demo.vue(表格导出、大文件大图、其它 mock、在线 URL / 文本流)。

接入要求:

  1. 插件目录为 uni_modules/lf-file-share
  2. Android 配置 FileProvider(系统分享)
  3. Android 9 及以下按需配置存储权限
  4. 导出接口设置 responseType: 'arraybuffer'
  5. 保存 / 分享显式传入 mime 与文件名
  6. 鸿蒙、uni-app x 在对应运行环境验证,不能只用 Android 基座代替

调用示例:

await downloadOrShareArrayBuffer(res.data, '销售报表.xlsx', {
  appAction: 'save',
  mime: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
})

问题反馈

插件:https://ext.dcloud.net.cn/plugin?id=26490

邮箱:lingfugroup@gmail.com

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐