更新记录

1.0.0(2026-09-22)

修复已知bug


平台兼容性

uni-app(4.31)

Vue2 Vue3 Chrome Safari app-vue app-nvue Android iOS 鸿蒙
- - - 5.0 12 -
微信小程序 支付宝小程序 抖音小程序 百度小程序 快手小程序 京东小程序 鸿蒙元服务 QQ小程序 飞书小程序 小红书小程序 快应用-华为 快应用-联盟
- - - - - - - - - - - -

文档预览(Android TBS / iOS QuickLook)

平台 预览实现 需要 LicenseKey 内核
Android 腾讯浏览服务文档 SDK(新版) 需要 已内置在 aar 中,无需下载
iOS 系统 QuickLook(QLPreviewController 不需要 系统自带

两种使用形态

形态 说明 适用页面
全屏预览 全屏展示,自带标题栏与关闭按钮 vue / nvue 均可
嵌入式预览 <tbs-doc-view> 宽高完全由页面决定,可做半屏、卡片、弹窗 只能 nvue

在线文档(http/https)两端都不支持直接打开:Android 的 filePath 只接受本地文件,iOS 的 previewItemURL 官方要求必须是 file:// URL。在线文档请先下载到本地再预览,见 5.2 在线文件


一、环境要求

  • HBuilderX ^3.6.18
  • 支持平台:App-Android、App-iOS(H5 / 小程序 / uni-app x 不支持)
  • 必须运行在「自定义调试基座」或正式包上:插件含 UTS 原生代码,标准基座无法运行
  • <tbs-doc-view> 组件只能在 nvue 页面中使用(uni-app 下只有 nvue 页面能承载原生扩展组件)

二、安装

uni_modules/lj-office-preview 目录放进项目即可(插件市场导入或手动拷贝),无需修改 pages.json

插件自带 AndroidManifest.xml,已声明所需权限(INTERNETMANAGE_EXTERNAL_STORAGE)。

装好后请重新制作自定义基座(Android 与 iOS 都要),否则原生代码不生效。


三、快速开始(三步)

第 1 步:申请 LicenseKey(仅 Android 需要)

腾讯云控制台路径:

浏览服务 TBS → 基础版 → 立即体验 → 实名认证 → 产品控制台 → 概览页 → 领取免费量包(75000 次)→ 复制 licensekey

⚠️ 不要把 key 写进 uni_modules/ 目录,原因有两个:

  1. uni_modules/lj-office-preview 下的文件在插件更新时会被整体覆盖,你的 key 会丢;
  2. 该 key 绑定你的腾讯云账号并有调用配额,不应随插件分发。

正确做法是放在自己的项目里:

// 你自己的配置文件,例如 utils/tbs-license.js
export const TBS_LICENSE_KEY = '你的 licensekey'

第 2 步:App 启动时初始化一次

// App.vue
import { initReaderOnce } from '@/uni_modules/lj-office-preview'
// #ifdef APP
import { TBS_LICENSE_KEY } from '@/utils/tbs-license.js'
// #endif

export default {
    onLaunch() {
        // #ifdef APP
        // Android:设置 key 并初始化引擎;iOS:空实现,不传也不会报错
        initReaderOnce(TBS_LICENSE_KEY)
        // #endif
    }
}

启动时调用一次即可。之后 openDocument* 系列与 <tbs-doc-view> 组件都可以不再传 key。

第 3 步:打开文档

import { openDocumentByPath } from '@/uni_modules/lj-office-preview'

// path:本地文件绝对路径(在线文档请先下载,见 5.2)
const err = openDocumentByPath(path)   // 空字符串 = 成功,否则为 "错误码:描述"
if (err) {
    uni.showToast({ title: err, icon: 'none' })
}

四、拿到本地文件路径的两种方式

4.1 让用户选择本地文件

plus.io.chooseFile({
    title: '选择文档',
    filetypes: ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'pdf', 'txt'],
    multiple: false
}, (e) => {
    const files = (e && e.files) ? e.files : []
    if (files.length <= 0) return
    // ⚠️ 不同版本返回的可能字符串、也可能是 File 对象,必须兼容
    const first = files[0]
    const p = typeof first === 'string' ? first : (first && first.path ? first.path : '')
    // p 可能是 file:// 或 _doc 相对路径,交给插件前建议转成绝对路径
    openDocumentByPath(toNativePath(p))
}, (err) => {
    console.log('选择失败', err)
})

这里的 toNativePath 是把 file:// 前缀去掉、把 _doc/xxx 转成绝对路径的小工具,示例见 5.2

4.2 下载在线文件

见下一节。


五、全屏预览

5.1 本地文件

import { openDocumentByPath, canOpenFile, getFileExtension } from '@/uni_modules/lj-office-preview'

function openLocal(path) {
    const ext = getFileExtension(path)       // 'pdf'
    if (!canOpenFile(ext)) {
        uni.showToast({ title: '不支持的格式:' + ext, icon: 'none' })
        return
    }
    const err = openDocumentByPath(path)
    if (err) {
        uni.showToast({ title: err, icon: 'none' })
    }
}

5.2 在线文件(先下载再预览)

两端都只能预览本地文件,所以在线文档需要先下载到本地。下面是可直接使用的最小实现:

import { toNativePath } from './path-helper'   // 见本节末尾

/** 从路径/网址里取后缀(自动去掉 ?query 和 #hash) */
export function extOf(source) {
    if (!source) return ''
    let p = String(source)
    const cut = p.search(/[?#]/)
    if (cut >= 0) p = p.substring(0, cut)
    const slash = p.lastIndexOf('/')
    const dot = p.lastIndexOf('.')
    if (dot < 0 || dot < slash) return ''
    return p.substring(dot + 1).toLowerCase()
}

/** 是否是在线地址 */
export function isRemoteSource(source) {
    return /^https?:\/\//i.test(String(source || ''))
}

/**
 * 统一入口:在线地址自动下载,本地路径直接返回
 * @returns {Promise<String>} 可直接交给插件的本地绝对路径
 */
export function resolveDocSource(source) {
    if (!source) return Promise.reject(new Error('文档来源为空'))
    if (isRemoteSource(source)) return downloadToLocal(source)
    return Promise.resolve(toNativePath(source))
}

/** 下载到本地,并保证路径带上正确后缀 */
export function downloadToLocal(url) {
    return new Promise((resolve, reject) => {
        const wantExt = extOf(url)
        uni.showLoading({ title: '下载中...', mask: true })
        uni.downloadFile({
            url,
            // 需要鉴权的接口可以在这里加 header: { Authorization: '...' }
            success: (res) => {
                if (res.statusCode !== 200) {
                    uni.hideLoading()
                    reject(new Error('下载失败,状态码 ' + res.statusCode))
                    return
                }
                const temp = toNativePath(res.tempFilePath)
                // 临时文件后缀正确就直接用
                if (!wantExt || extOf(temp) === wantExt) {
                    uni.saveFile({
                        tempFilePath: res.tempFilePath,
                        success: (s) => {
                            uni.hideLoading()
                            resolve(toNativePath(s.savedFilePath))
                        },
                        fail: () => {
                            uni.hideLoading()
                            resolve(temp)
                        }
                    })
                    return
                }
                // 后缀丢了(iOS 的 saveFile 会返回无后缀路径)→ 落盘后补后缀
                uni.saveFile({
                    tempFilePath: res.tempFilePath,
                    success: (s) => {
                        ensureLocalExt(toNativePath(s.savedFilePath), wantExt).then((p) => {
                            uni.hideLoading()
                            resolve(p)
                        })
                    },
                    fail: () => {
                        uni.hideLoading()
                        resolve(temp)
                    }
                })
            },
            fail: (e) => {
                uni.hideLoading()
                reject(e)
            }
        })
    })
}

/** 保证本地路径带正确后缀(TBS / QuickLook 都靠后缀判断文件类型) */
export function ensureLocalExt(path, ext) {
    return new Promise((resolve) => {
        const want = String(ext || '').toLowerCase()
        if (!path || !want || extOf(path) === want) {
            resolve(path)
            return
        }
        const name = 'doc_' + Date.now() + '.' + want
        try {
            plus.io.resolveLocalFileSystemURL(path, (entry) => {
                plus.io.resolveLocalFileSystemURL('_doc/', (dir) => {
                    entry.moveTo(dir, name, () => resolve(toNativePath('_doc/' + name)), () => resolve(path))
                }, () => resolve(path))
            }, () => resolve(path))
        } catch (e) {
            resolve(path)
        }
    })
}

/** _doc / file:// 等相对路径 → 本地绝对路径 */
export function toNativePath(path) {
    if (path && typeof path === 'object') {
        path = path.path || path.fullPath || ''
    }
    if (!path || typeof path !== 'string') return ''
    let p = path
    if (p.indexOf('file://') === 0) p = p.substring(7)
    if (/^_/.test(p)) {
        const abs = plus.io.convertLocalFileSystemURL(p)
        return abs.indexOf('file://') === 0 ? abs.substring(7) : abs
    }
    return p
}

用法(在线、本地都走同一个入口):

import { resolveDocSource } from '@/utils/doc.js'
import { openDocumentByPath } from '@/uni_modules/lj-office-preview'

function preview(source) {          // source 可以是 https 地址,也可以是本地路径
    resolveDocSource(source)
        .then((localPath) => {
            const err = openDocumentByPath(localPath)
            if (err) uni.showToast({ title: err, icon: 'none' })
        })
        .catch((e) => {
            uni.showToast({ title: '失败:' + (e && e.message ? e.message : e), icon: 'none' })
        })
}

preview('https://example.com/报告.pdf')
preview('/var/mobile/.../文件.pdf')

几个必须注意的点

  1. 文件名后缀不能少:TBS 与 QuickLook 都靠文件名后缀判断类型。iOS 的 uni.saveFile 会返回无后缀路径(如 _doc/uniapp_save/XXXX),必须补上后缀,否则报"不支持的文件格式"。
  2. URL 带 query 也能取对后缀.../file.pdf?token=abc 要先把 ?token=abc 去掉再取 .pdf
  3. 需要鉴权的地址uni.downloadFile 支持 header,可以放 token / cookie,但不能把它直接交给插件(插件只吃本地路径)。
  4. 文件名含中文/空格:正常支持,不要自己做 URL 编码,交给 uni.downloadFile 处理。
  5. 大文件:预览前需等下载完成,建议自行加进度提示(uni.downloadFileonProgressUpdate)。

5.3 三种调用形态(都在两端可用)

import { openDocumentByPath, openDocumentFlat, openDocument } from '@/uni_modules/lj-office-preview'

// ① 单字符串参数(推荐,iOS 兼容性最好)
const err = openDocumentByPath(path)                       // '' = 成功

// ② 多字符串参数
const r1 = openDocumentFlat(path, 'pdf', '文档预览', '')    // { code, message }

// ③ 对象参数(建议仅在 Android 使用)
const r2 = openDocument({ filePath: path, fileExt: 'pdf', title: '文档预览', licenseKey: '' })

iOS 请优先用 openDocumentByPath。iOS 的原生桥接对"对象参数 / 回调参数 / 多参数"不够稳定,出问题时表现为 method call failed(函数体一行都不执行)。因此插件对 iOS 只暴露标量参数入口。

重要:原生调用一定要包 try/catch

try {
    openDocumentByPath(path)
} catch (e) {
    // 桥接异常不捕获会直接终止 App(不是崩溃,是未捕获的 JS 异常)
    uni.showToast({ title: '打开失败:' + e.message, icon: 'none' })
}

六、嵌入式预览(半屏 / 卡片 / 弹窗)

只能在 nvue 页面使用。 vue 页面里写 <tbs-doc-view> 不会渲染(官方限制:uni-app 下只有 nvue 页面能放置原生扩展组件)。

6.1 nvue 页面完整示例

<template>
    <div class="wrap">
        <!-- 上半屏:文档区。原生组件必须给具体尺寸,flex 撑不开 -->
        <div class="doc-area" :style="{ height: docHeight + 'px' }">
            <tbs-doc-view
                ref="docView"
                :path="docPath"
                :fileext="docExt"
                :token="openToken"
                :licensekey="licenseKey"
                :viewwidth="docWidth"
                :viewheight="docHeight"
                @docstate="onDocState"
            ></tbs-doc-view>
        </div>

        <!-- 下半屏:业务区 -->
        <div class="biz-area">
            <text>{{ stateText }}</text>
            <button text="重新加载" @tap="onReopen"></button>
            <button text="关闭" @tap="onClose"></button>
        </div>
    </div>
</template>

<script>
export default {
    data() {
        return {
            docPath: '',
            docExt: '',
            licenseKey: '',
            openToken: 0,
            docWidth: 0,
            docHeight: 0,
            stateText: '准备中'
        }
    },
    onLoad() {
        // 原生组件在 nvue 里靠 flex 拿不到高度,必须算出具体 px
        const info = uni.getSystemInfoSync()
        const winW = info.windowWidth > 0 ? info.windowWidth : 375
        const winH = info.windowHeight > 0 ? info.windowHeight : 667
        const bizH = Math.round(420 * winW / 750)   // 业务区 420rpx
        this.docWidth = winW
        this.docHeight = winH - bizH

        // 从上一页拿路径
        const app = getApp()
        const g = (app && app.globalData) ? app.globalData : {}
        this.docPath = g.docPath || ''
        this.docExt = g.docExt || ''
        this.licenseKey = g.docLicenseKey || ''
    },
    methods: {
        onDocState(e) {
            // 载荷是 Map:e.get('state') / e.get('message')
            let state = ''
            let message = ''
            if (e && typeof e.get === 'function') {
                state = e.get('state')
                message = e.get('message')
            } else if (e) {
                state = e.state
                message = e.message
            }
            this.stateText = (state || '') + ' ' + (message || '')
        },
        onReopen() {
            this.openToken = this.openToken + 1      // 改 token 即触发重新打开
        },
        onClose() {
            const ref = this.$refs.docView
            const target = Array.isArray(ref) ? ref[0] : ref   // nvue 下 ref 可能是数组
            if (target) target.close()
        }
    }
}
</script>

<style>
.wrap { flex: 1; flex-direction: column; background-color: #ffffff; }
.doc-area { background-color: #f0f2f5; }
.biz-area { height: 420rpx; padding: 24rpx; background-color: #ffffff; }
</style>

6.2 属性

Android 与 iOS 支持的属性不同,请注意区分:

属性 平台 类型 说明
path 两端 String 本地文件绝对路径(在线请先下载)
fileext Android String 文件后缀;不传时自动从 path 截取
fileExt iOS String 同上(iOS 端属性名是驼峰 fileExt
token Android Number 自增计数器,改变它的值即触发打开(nvue 下直接调方法有时不生效)
licensekey Android String LicenseKey;已在 App.vue 初始化过则可不传(prop 名必须全小写
viewwidth Android Number 嵌入区宽度,单位 CSS px
viewheight Android Number 嵌入区高度,单位 CSS px

iOS 端没有 token / licensekey / viewwidth / viewheight:iOS 用系统 QuickLook,不需要 key,尺寸通过 style 指定(见 6.4),触发打开靠path 或调用 openFile()

组件标签是 tbs-doc-view(历史命名),与插件 ID lj-office-preview 不同名,属正常情况。

6.3 事件与方法

类型 名称 说明
事件 docstate 载荷为 Map,键 state / message
方法 openFile(path) 手动打开(需通过 ref 调用)
方法 close() 关闭并释放

state 的实际取值:

含义
loading 正在加载(已验证路径与格式,交给原生渲染中)
error 失败,message 是原因(文件不存在 / 格式不支持 / 引擎未初始化)
closed 已关闭

没有"加载完成"事件:原生 SDK 不回调渲染完成,只能通过 loading 之后没有 error 来判断。

ref 取实例(nvue 下可能是数组):

const ref = this.$refs.docView
const target = Array.isArray(ref) ? ref[0] : ref
if (target && typeof target.openFile === 'function') {
    target.openFile(this.docPath)
}

6.4 尺寸与弹窗

尺寸必须显式指定,原生组件在 nvue 里靠 flex: 1 拿不到高度,会按错误尺寸渲染导致错位。

平台 怎么指定
Android :viewwidth / :viewheightCSS px,插件内部会换算物理像素);也可用 style 设置组件占位
iOS style 设置宽高(如 style="width:750rpx;height:600rpx"

Android 端的兜底行为(建议永远显式传值):

  • viewwidth ≤ 0 → 用屏宽;
  • viewheight ≤ 0 → 用 屏高 − 420rpx
  • 组件尚未完成布局时,先用「整屏宽 × 半屏高」占位,布局完成后自动纠正。

放进弹窗(必须在 nvue 页面里自己做弹窗):

<view class="mask" v-if="show">
    <view class="card" :style="{ width: popW + 'px', height: popH + 'px' }">
        <tbs-doc-view
            :path="docPath"
            :viewwidth="popW"
            :viewheight="popH"
            :token="openToken"
            @docstate="onDocState"
        ></tbs-doc-view>
    </view>
</view>

不能用 vue 页面的 uni-popup 之类组件包它 —— 那是 webview 弹窗,里面的原生组件不会渲染。

Android 端文档视图显示在组件所在位置的最上层:盖在组件上方的遮罩不会遮挡文档本身;弹窗做位移动画时,文档区域要等布局稳定后才对齐,快速拖动可能有短暂错位。


七、API 一览

两端导出完全一致(iOS 侧部分接口是空实现,保证跨端代码无需条件编译)。

API 返回 说明
initReaderOnce(licenseKey?) void Android 设置 key 并初始化引擎(建议 App.vue onLaunch 调用一次);iOS 空实现
isReaderReady() boolean 引擎是否就绪(iOS 恒 true
utsBuildTag() string 插件版本探针,用于确认安装包里的原生插件是否为最新
canOpenFile(ext) boolean 后缀是否受支持
getFileExtension(path, ext?) string 取文件后缀(统一转小写)
fileExists(path) boolean 本地文件是否存在
getTempDir() string Android 的 SDK 临时目录(iOS 返回空串)
getTbsVersion() number SDK 版本号(iOS 恒 0
getTbsStatus() TbsStatus 引擎状态 { progress, state, message, version }
startCoreDownload() boolean 新版 SDK 内核已内置,无需下载;保留接口做跨端一致
hasAllFilesAccess() boolean 是否已获得"所有文件访问权限"(iOS 恒 true
requestAllFilesAccess() void 申请"所有文件访问权限"(Android 11+ 跳系统设置页)
openDocumentByPath(filePath) string 推荐:全屏打开,返回空串 = 成功
openDocumentFlat(filePath, fileExt, title, licenseKey) TbsDocResult 多字符串参数版全屏打开
openDocument(options) TbsDocResult 对象参数版全屏打开(建议仅 Android 使用)
closeDocument() void 关闭全屏预览并释放资源

八、支持的文件格式

doc docx rtf ppt pptx xls xlsx xlsm csv pdf txt epub chm

ofd 需要在 Android 端额外引入一组依赖(kotlinx-coroutines、commons-io、dom4j、bcpkix、zip4j、pdfbox-android 等)并配置混淆规则,本插件默认未打包。


九、错误码与常见问题

错误码

code 含义 处理
0 成功
-1 不支持的文件格式 检查后缀,或文件是否没有后缀(见 5.2 第 1 点)
-2 文件不存在或不可读 确认路径是本地绝对路径、文件已下载完成
-3 获取 Activity 失败 极少数时序问题,稍后重试
-4 文件无读取权限 见第十一节(Android 11+ 路径问题)
-100 拿不到 App 上下文 确认在 App 环境调用
-101 LicenseKey 为空 见第三节

method call failed

说明当前安装包里的原生插件是旧版本。UTS 原生代码改动的产物不能通过 wgt / 热更更新,必须重新制作自定义调试基座或重新整包。

自检方式:调用 utsBuildTag(),能返回字符串即说明原生插件是新的。本版本期望值:

平台 期望返回
Android lj-preview-android-1
iOS lj-preview-ios-1

约定:每次改动 UTS 原生代码后把末尾数字往上加,便于一眼确认基座是否为最新。

点开就退出应用(不是崩溃)

多半是未捕获的 JS 异常。iOS 桥接失败时抛的异常如果没人接,运行时会直接终止 App。任何原生调用都要包 try/catch(见 5.3)。

嵌入式区域一片空白

  1. 页面是不是 nvue
  2. Android 是否传了有效的 viewwidth / viewheight?iOS 是否用 style 设了宽高?
  3. 路径是否已经下载完成、且带正确后缀
  4. @docstate 是否报 error

打开后一两秒闪退(Android)

已修复。原因是 SDK 在主线程操作视图,而调用发生在 JS 线程。插件内部已统一把"关旧会话 + 开新会话"放到主线程执行,请勿绕过插件直接调用 TBS SDK

全屏关闭后再打开一直停在"预览中"

已修复。SDK 是全局单例,关闭时必须真正释放会话。插件已在文档界面关闭回调里做了释放,全屏与嵌入式来回切换也已处理。


十、打开速度与体验说明

  • 全屏预览:Android 走 SDK 自带 Dialog(自带标题栏与关闭按钮);iOS 用 present 弹出 QuickLook。
  • 首次打开:Android 首次初始化引擎会稍慢(毫秒级到百毫秒级,取决于设备);iOS 无初始化成本。
  • 在线文档:需要先下载,等待时间取决于文件大小与网速,请自行加进度提示。
  • 大体积文档:建议先下载到应用私有目录再预览,避免每次重复下载。

十一、Android 文件路径与权限

Android 11+ 无法直接读取公共目录(微信 / QQ 下载目录、Downloads 等),直读会报 EACCES。请通过系统文件选择器等方式取得路径,让系统授予临时读取权限后再交给插件。

  • 插件内部会自动把读不到的文件复制到应用私有目录再打开(同一文件会复用已复制的副本);
  • 需要读取其他应用目录时,可先引导用户授予"所有文件访问权限":
import { hasAllFilesAccess, requestAllFilesAccess } from '@/uni_modules/lj-office-preview'

if (!hasAllFilesAccess()) {
    requestAllFilesAccess()   // 跳系统设置页
}
  • iOS 无此限制。

十二、更新日志

v1.0.0(首个版本)

Android

  • 腾讯浏览服务文档 SDK(新版,内核已内置,无需下载),支持全屏预览与嵌入式预览
  • Android 11+ 公共目录文件自动复制到应用私有目录后打开
  • 修复「打开预览一两秒后闪退」
  • 修复「全屏与嵌入式来回切换时闪退」
  • 修复全屏预览关闭后再次打开会一直停留在"预览中"

iOS

  • 系统 QuickLook,支持全屏预览与 nvue 组件嵌入
  • 修复一点开就退出应用(QLPreviewItem 必须返回 NSURLURL 不遵守该协议)
  • 新增单参数入口 openDocumentByPath,规避桥接对对象/回调参数的兼容问题

通用

  • 统一 16 个跨端 API,页面侧无需区分平台
  • 插件 ID 为 lj-office-preview;组件标签 tbs-doc-view、自检串 lj-preview-android-1 / lj-preview-ios-1 为独立命名,不受插件 ID 影响

隐私、权限声明

1. 本插件需要申请的系统权限列表:

2. 本插件采集的数据、发送的服务器地址、以及数据用途说明:

3. 本插件是否包含广告,如包含需详细说明广告表达方式、展示频率:

暂无用户评论。