HarmonyOS开发:文档管理——文档协作
HarmonyOS开发:文档管理——文档协作
📌 核心要点:文档协作不是简单的"上传下载"——在线预览要跨格式、多人编辑要防冲突、版本管理要可追溯,三个难题一个比一个棘手。
背景与动机
你打开OA系统,想看一份项目方案文档。点开——下载中…下载完成…打开失败,手机上没有对应的软件。换一种格式?PDF可以预览,但你想改几个字怎么办?
你改完文档保存了,同事说他也在改同一份文档,他保存后你的修改全没了。你找IT,IT说"你们不能同时编辑同一个文档"。那多人协作怎么搞?
你记得上周改过一个关键数据,但现在文档里不是那个数了。谁改的?什么时候改的?改之前是什么?你翻遍了历史记录也找不到。
文档管理的三个核心难题:
- 在线预览:Word、Excel、PPT、PDF、图片,格式五花八门,手机上怎么预览?
- 多人协作:两个人同时编辑,怎么保证不冲突?冲突了怎么合并?
- 版本管理:改了什么、谁改的、什么时候改的,怎么追溯?
鸿蒙做文档管理有个天然优势——分布式能力。手机上预览,平板上编辑,PC上排版,文档在设备间无缝流转。但这个优势要发挥出来,协作和版本管理必须做好。
核心原理
文档协作的架构核心:云端存储 + 本地缓存 + 操作转换(OT) + 版本快照。
flowchart TD
A[用户A打开文档] --> B[加载云端最新版本]
C[用户B打开文档] --> B
B --> D[本地缓存副本]
B --> E[本地缓存副本]
D --> F[用户A编辑]
E --> G[用户B编辑]
F --> H[操作日志记录]
G --> I[操作日志记录]
H --> J[操作转换OT引擎]
I --> J
J --> K{冲突检测}
K -->|无冲突| L[合并操作]
K -->|有冲突| M[冲突解决策略]
M --> N{自动合并?}
N -->|可以| L
N -->|不行| O[手动解决冲突]
O --> P[用户选择保留版本]
P --> L
L --> Q[生成新版本快照]
Q --> R[同步到云端]
R --> S[通知其他协作者]
S --> D
S --> E
classDef user fill:#1565C0,color:#fff,stroke:#0D47A1
classDef local fill:#2E7D32,color:#fff,stroke:#1B5E20
classDef engine fill:#E65100,color:#fff,stroke:#BF360C
classDef conflict fill:#C62828,color:#fff,stroke:#B71C1C
classDef sync fill:#6A1B9A,color:#fff,stroke:#4A148C
class A,C,F,G user
class D,E,H,I local
class J,K,L engine
class M,N,O,P conflict
class Q,R,S sync
文档预览方案
鸿蒙端文档预览有三种方案:
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 服务端转换 | 服务端把Word/Excel转成PDF或图片,客户端只渲染图片 | 兼容性最好 | 服务器压力大,转换慢 |
| 原生渲染 | 客户端直接解析文档格式并渲染 | 不依赖服务端 | 格式兼容性差,开发量大 |
| WebView嵌入 | 用WebView加载在线文档编辑器 | 功能最全 | 体验差,离线不可用 |
推荐组合:服务端转换PDF预览 + 原生渲染图片/文本 + WebView嵌入编辑。预览走轻量方案,编辑走WebView。
操作转换(OT)算法
多人协作的核心算法——操作转换(Operational Transformation)。
假设用户A和用户B同时编辑同一段文字:
原文: "Hello World"
用户A: 在位置5插入" Dear" → "Hello Dear World"
用户B: 在位置5插入" Beautiful" → "Hello Beautiful World"
如果先执行A的操作再执行B的,B的插入位置应该变成5+5=10,结果是"Hello Dear Beautiful World"。
如果先执行B的操作再执行A的,A的插入位置应该变成5+10=15,结果是"Hello Beautiful Dear World"。
两种结果不一样!OT算法就是解决这个问题的——根据已执行的操作,转换后续操作的位置,保证最终结果一致。
版本管理策略
文档每次保存产生一个版本快照。但不可能每次保存都存完整文档——太浪费空间。
增量存储:只存与上一版本的差异(diff)。恢复时从基础版本开始,逐步应用差异。
版本快照策略:
- 每次保存记录操作日志
- 每10次保存生成一个完整快照
- 恢复时找最近的快照,再应用差异
代码实战
基础用法:文档在线预览
先实现文档预览——支持PDF、图片、纯文本的本地预览。
// DocPreviewManager.ets - 文档预览管理
import { fileIo as fs } from '@kit.CoreFileKit';
import { common } from '@kit.AbilityKit';
// 文档类型
export enum DocType {
PDF = 'pdf',
IMAGE = 'image',
TEXT = 'text',
WORD = 'word',
EXCEL = 'excel',
PPT = 'ppt',
UNKNOWN = 'unknown',
}
// 文档信息
export interface DocInfo {
docId: string; // 文档ID
name: string; // 文件名
type: DocType; // 文档类型
size: number; // 文件大小(字节)
url: string; // 下载URL
localPath: string; // 本地缓存路径
version: number; // 版本号
lastModified: number; // 最后修改时间
thumbnailUrl?: string; // 缩略图URL
}
export class DocPreviewManager {
private static instance: DocPreviewManager;
private context: common.UIAbilityContext | null = null;
private cacheDir: string = '';
private constructor() {}
static getInstance(): DocPreviewManager {
if (!DocPreviewManager.instance) {
DocPreviewManager.instance = new DocPreviewManager();
}
return DocPreviewManager.instance;
}
init(context: common.UIAbilityContext): void {
this.context = context;
this.cacheDir = context.cacheDir + '/doc_preview';
// 确保缓存目录存在
if (!fs.accessSync(this.cacheDir)) {
fs.mkdirSync(this.cacheDir);
}
console.info('[DocPreview] 初始化完成');
}
// 根据文件扩展名判断文档类型
getDocType(fileName: string): DocType {
const ext = fileName.split('.').pop()?.toLowerCase() || '';
const typeMap: Record<string, DocType> = {
'pdf': DocType.PDF,
'jpg': DocType.IMAGE, 'jpeg': DocType.IMAGE, 'png': DocType.IMAGE,
'gif': DocType.IMAGE, 'webp': DocType.IMAGE, 'bmp': DocType.IMAGE,
'txt': DocType.TEXT, 'md': DocType.TEXT, 'json': DocType.TEXT,
'doc': DocType.WORD, 'docx': DocType.WORD,
'xls': DocType.EXCEL, 'xlsx': DocType.EXCEL,
'ppt': DocType.PPT, 'pptx': DocType.PPT,
};
return typeMap[ext] || DocType.UNKNOWN;
}
// 检查文档是否已缓存到本地
isCached(docId: string): boolean {
const cachedPath = `${this.cacheDir}/${docId}`;
return fs.accessSync(cachedPath);
}
// 获取本地缓存路径
getCachedPath(docId: string, fileName: string): string {
return `${this.cacheDir}/${docId}_${fileName}`;
}
// 下载文档到本地缓存
async downloadDoc(docInfo: DocInfo, onProgress?: (percent: number) => void): Promise<string | null> {
const localPath = this.getCachedPath(docInfo.docId, docInfo.name);
// 已缓存则直接返回
if (fs.accessSync(localPath)) {
console.info(`[DocPreview] 使用缓存: ${localPath}`);
return localPath;
}
try {
// 使用HTTP下载文档
const httpRequest = await import('@kit.NetworkKit').then(m => m.http.createHttp());
const response = await httpRequest.requestInStream(docInfo.url, {
method: 'GET',
expectDataType: 'arraybuffer',
});
// 写入本地文件
const file = fs.openSync(localPath, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE);
fs.writeSync(file.fd, response.result as ArrayBuffer);
fs.closeSync(file);
console.info(`[DocPreview] 下载完成: ${localPath}`);
return localPath;
} catch (error) {
console.error(`[DocPreview] 下载失败: ${JSON.stringify(error)}`);
return null;
}
}
// 读取文本文件内容
readTextContent(filePath: string): string {
try {
return fs.readTextSync(filePath, { encoding: 'utf-8' });
} catch (error) {
console.error(`[DocPreview] 读取文本失败: ${JSON.stringify(error)}`);
return '';
}
}
// 清理缓存
clearCache(): void {
try {
if (fs.accessSync(this.cacheDir)) {
// 删除缓存目录下的所有文件
const files = fs.listFileSync(this.cacheDir);
for (const file of files) {
fs.unlinkSync(`${this.cacheDir}/${file}`);
}
console.info('[DocPreview] 缓存已清理');
}
} catch (error) {
console.error(`[DocPreview] 清理缓存失败: ${JSON.stringify(error)}`);
}
}
}
进阶用法:多人协作与冲突处理
多人协作的核心——操作日志、冲突检测、自动合并。
// DocCollaboration.ets - 文档协作引擎
import { relationalStore } from '@kit.ArkData';
import { emitter } from '@kit.BasicServicesKit';
// 操作类型
export enum OperationType {
INSERT = 'insert', // 插入文本
DELETE = 'delete', // 删除文本
REPLACE = 'replace', // 替换文本
FORMAT = 'format', // 格式变更(加粗、斜体等)
}
// 操作记录
export interface DocOperation {
opId: string; // 操作ID
docId: string; // 文档ID
userId: string; // 操作人ID
opType: OperationType; // 操作类型
position: number; // 操作位置(字符偏移量)
content: string; // 操作内容
oldContent?: string; // 被替换的旧内容(用于撤销)
timestamp: number; // 操作时间
baseVersion: number; // 基于哪个版本
}
// 冲突类型
export enum ConflictType {
POSITION_CONFLICT = 'position', // 位置冲突:两人修改了相邻区域
CONTENT_CONFLICT = 'content', // 内容冲突:两人修改了同一区域
DELETE_CONFLICT = 'delete', // 删除冲突:一人删除另一人正在编辑的区域
}
// 冲突记录
export interface ConflictRecord {
conflictId: string;
docId: string;
localOp: DocOperation; // 本地操作
remoteOp: DocOperation; // 远程操作
conflictType: ConflictType;
resolution?: 'local' | 'remote' | 'merge'; // 解决方式
}
// 版本快照
export interface DocVersion {
versionId: string;
docId: string;
version: number;
content: string; // 完整文档内容
checksum: string; // 内容校验和
creator: string; // 创建者
timestamp: number; // 创建时间
operations: DocOperation[]; // 从上一版本到本版本的操作列表
}
export class DocCollaboration {
private static instance: DocCollaboration;
// 操作日志缓冲区
private operationBuffer: Map<string, DocOperation[]> = new Map();
// 版本历史
private versionHistory: Map<string, DocVersion[]> = new Map();
// 当前文档内容
private docContents: Map<string, string> = new Map();
// 协作者列表
private collaborators: Map<string, Set<string>> = new Map();
private constructor() {}
static getInstance(): DocCollaboration {
if (!DocCollaboration.instance) {
DocCollaboration.instance = new DocCollaboration();
}
return DocCollaboration.instance;
}
// 打开文档进行协作
openDoc(docId: string, userId: string, initialContent: string): void {
this.docContents.set(docId, initialContent);
if (!this.collaborators.has(docId)) {
this.collaborators.set(docId, new Set());
}
this.collaborators.get(docId)?.add(userId);
console.info(`[DocCollab] 用户${userId}加入文档${docId}协作`);
}
// 离开文档协作
leaveDoc(docId: string, userId: string): void {
this.collaborators.get(docId)?.delete(userId);
console.info(`[DocCollab] 用户${userId}离开文档${docId}协作`);
}
// 应用本地操作
applyLocalOperation(op: DocOperation): void {
const content = this.docContents.get(op.docId) || '';
let newContent = '';
switch (op.opType) {
case OperationType.INSERT:
// 在指定位置插入内容
newContent = content.slice(0, op.position) + op.content + content.slice(op.position);
break;
case OperationType.DELETE:
// 删除指定位置的内容
const deleteLen = op.content.length;
newContent = content.slice(0, op.position) + content.slice(op.position + deleteLen);
break;
case OperationType.REPLACE:
// 替换指定位置的内容
newContent = content.slice(0, op.position) + op.content + content.slice(op.position + (op.oldContent?.length || 0));
break;
default:
newContent = content;
}
this.docContents.set(op.docId, newContent);
// 记录操作到缓冲区
if (!this.operationBuffer.has(op.docId)) {
this.operationBuffer.set(op.docId, []);
}
this.operationBuffer.get(op.docId)?.push(op);
// 通知其他协作者
emitter.emit({ eventId: 2001 }, {
data: { docId: op.docId, opId: op.opId, userId: op.userId }
});
}
// 接收远程操作——需要做操作转换
applyRemoteOperation(remoteOp: DocOperation): ConflictRecord | null {
const localOps = this.operationBuffer.get(remoteOp.docId) || [];
// 找出与远程操作并发的本地操作(基于同一版本的操作)
const concurrentOps = localOps.filter(op =>
op.baseVersion === remoteOp.baseVersion && op.userId !== remoteOp.userId
);
if (concurrentOps.length === 0) {
// 没有并发操作,直接应用
this.transformAndApply(remoteOp, []);
return null;
}
// 有并发操作,检测冲突
for (const localOp of concurrentOps) {
const conflict = this.detectConflict(localOp, remoteOp);
if (conflict) {
console.warn(`[DocCollab] 检测到冲突: ${conflict.conflictType}`);
return conflict;
}
}
// 无冲突,转换后应用
this.transformAndApply(remoteOp, concurrentOps);
return null;
}
// 检测两个操作是否冲突
private detectConflict(localOp: DocOperation, remoteOp: DocOperation): ConflictRecord | null {
// 位置不重叠,不冲突
const localEnd = localOp.position + (localOp.content?.length || 0);
const remoteEnd = remoteOp.position + (remoteOp.content?.length || 0);
if (localOp.position >= remoteEnd || remoteOp.position >= localEnd) {
return null; // 操作区域不重叠
}
// 操作区域重叠,判断冲突类型
let conflictType = ConflictType.CONTENT_CONFLICT;
if (localOp.opType === OperationType.DELETE || remoteOp.opType === OperationType.DELETE) {
conflictType = ConflictType.DELETE_CONFLICT;
} else if (Math.abs(localOp.position - remoteOp.position) < 5) {
conflictType = ConflictType.POSITION_CONFLICT;
}
return {
conflictId: `conflict_${Date.now()}`,
docId: localOp.docId,
localOp,
remoteOp,
conflictType,
};
}
// 操作转换并应用
private transformAndApply(op: DocOperation, concurrentOps: DocOperation[]): void {
let transformedOp = { ...op };
// 根据已执行的并发操作,调整当前操作的位置
for (const executedOp of concurrentOps) {
if (executedOp.opType === OperationType.INSERT && executedOp.position <= transformedOp.position) {
// 前面有插入,位置后移
transformedOp.position += executedOp.content.length;
} else if (executedOp.opType === OperationType.DELETE && executedOp.position < transformedOp.position) {
// 前面有删除,位置前移
transformedOp.position -= executedOp.content.length;
}
}
// 应用转换后的操作
this.applyLocalOperation(transformedOp);
}
// 解决冲突
resolveConflict(conflict: ConflictRecord, resolution: 'local' | 'remote' | 'merge'): void {
conflict.resolution = resolution;
switch (resolution) {
case 'local':
// 保留本地版本,丢弃远程操作
console.info('[DocCollab] 冲突解决:保留本地版本');
break;
case 'remote':
// 使用远程版本,覆盖本地操作
this.applyLocalOperation(conflict.remoteOp);
console.info('[DocCollab] 冲突解决:使用远程版本');
break;
case 'merge':
// 尝试合并——实际场景中需要更智能的合并策略
console.info('[DocCollab] 冲突解决:尝试合并');
break;
}
}
// 保存版本快照
saveVersion(docId: string, userId: string): DocVersion {
const content = this.docContents.get(docId) || '';
const versions = this.versionHistory.get(docId) || [];
const lastVersion = versions.length > 0 ? versions[versions.length - 1] : null;
const newVersionNum = (lastVersion?.version || 0) + 1;
const version: DocVersion = {
versionId: `v_${docId}_${newVersionNum}`,
docId,
version: newVersionNum,
content,
checksum: this.calculateChecksum(content),
creator: userId,
timestamp: Date.now(),
operations: this.operationBuffer.get(docId) || [],
};
versions.push(version);
this.versionHistory.set(docId, versions);
// 清空操作缓冲区
this.operationBuffer.set(docId, []);
console.info(`[DocCollab] 版本${newVersionNum}已保存`);
return version;
}
// 获取版本历史
getVersionHistory(docId: string): DocVersion[] {
return this.versionHistory.get(docId) || [];
}
// 回滚到指定版本
rollbackToVersion(docId: string, targetVersion: number): boolean {
const versions = this.versionHistory.get(docId) || [];
const target = versions.find(v => v.version === targetVersion);
if (!target) {
console.error(`[DocCollab] 版本${targetVersion}不存在`);
return false;
}
this.docContents.set(docId, target.content);
console.info(`[DocCollab] 已回滚到版本${targetVersion}`);
return true;
}
// 计算内容校验和(简化实现)
private calculateChecksum(content: string): string {
let hash = 0;
for (let i = 0; i < content.length; i++) {
const char = content.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // 转为32位整数
}
return hash.toString(16);
}
// 获取当前文档内容
getDocContent(docId: string): string {
return this.docContents.get(docId) || '';
}
}
完整示例:文档管理页面
把预览、协作、版本管理串起来,做一个完整的文档管理页面。
// DocManagementPage.ets - 文档管理页面
import { DocPreviewManager, DocInfo, DocType } from '../doc/DocPreviewManager';
import { DocCollaboration, DocVersion, OperationType } from '../doc/DocCollaboration';
@Entry
@Component
struct DocManagementPage {
@State docList: DocInfo[] = [];
@State selectedDoc: DocInfo | null = null;
@State docContent: string = '';
@State versionList: DocVersion[] = [];
@State showVersionPanel: boolean = false;
@State isEditing: boolean = false;
@State searchText: string = '';
@State viewMode: 'grid' | 'list' = 'list';
private previewManager: DocPreviewManager = DocPreviewManager.getInstance();
private collaboration: DocCollaboration = DocCollaboration.getInstance();
aboutToAppear(): void {
this.loadDocList();
}
// 加载文档列表
private loadDocList(): void {
// 模拟数据
this.docList = [
{ docId: 'doc_001', name: '项目方案.docx', type: DocType.WORD, size: 1024000, url: '', localPath: '', version: 3, lastModified: Date.now() - 86400000, thumbnailUrl: '' },
{ docId: 'doc_002', name: '财务报表.xlsx', type: DocType.EXCEL, size: 512000, url: '', localPath: '', version: 1, lastModified: Date.now() - 172800000, thumbnailUrl: '' },
{ docId: 'doc_003', name: '会议纪要.txt', type: DocType.TEXT, size: 2048, url: '', localPath: '', version: 5, lastModified: Date.now() - 3600000, thumbnailUrl: '' },
{ docId: 'doc_004', name: '产品截图.png', type: DocType.IMAGE, size: 307200, url: '', localPath: '', version: 1, lastModified: Date.now() - 7200000, thumbnailUrl: '' },
{ docId: 'doc_005', name: '技术方案.pdf', type: DocType.PDF, size: 2048000, url: '', localPath: '', version: 2, lastModified: Date.now() - 43200000, thumbnailUrl: '' },
];
}
build() {
Column() {
// 顶部搜索栏
this.SearchBar()
// 文档列表
if (this.selectedDoc) {
// 文档详情/预览模式
this.DocDetailView()
} else {
// 文档列表模式
this.DocListView()
}
}
.width('100%')
.height('100%')
.backgroundColor('#F5F5F5')
}
// 搜索栏
@Builder
SearchBar() {
Row() {
Search({ placeholder: '搜索文档...' })
.width('70%')
.height(40)
.onChange((value: string) => {
this.searchText = value;
})
// 视图切换
Row() {
Image(this.viewMode === 'list' ? $r('app.media.ic_list_selected') : $r('app.media.ic_list'))
.width(24).height(24)
.onClick(() => { this.viewMode = 'list'; })
.margin({ right: 8 })
Image(this.viewMode === 'grid' ? $r('app.media.ic_grid_selected') : $r('app.media.ic_grid'))
.width(24).height(24)
.onClick(() => { this.viewMode = 'grid'; })
}
// 新建文档
Image($r('app.media.ic_add'))
.width(28).height(28)
.fillColor('#1565C0')
.margin({ left: 12 })
}
.width('100%')
.padding({ left: 16, right: 16, top: 8, bottom: 8 })
.backgroundColor(Color.White)
}
// 文档列表
@Builder
DocListView() {
List({ space: 8 }) {
ForEach(this.docList, (doc: DocInfo) => {
ListItem() {
this.DocListItem(doc)
}
})
}
.padding(16)
.layoutWeight(1)
}
// 文档列表项
@Builder
DocListItem(doc: DocInfo) {
Row() {
// 文件图标
Column() {
Image(this.getDocIcon(doc.type))
.width(40).height(40)
.fillColor(this.getDocIconColor(doc.type))
}
.width(56).height(56)
.justifyContent(FlexAlign.Center)
.borderRadius(12)
.backgroundColor(this.getDocBgColor(doc.type))
// 文件信息
Column() {
Text(doc.name)
.fontSize(15)
.fontWeight(FontWeight.Medium)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Row() {
Text(this.formatFileSize(doc.size))
.fontSize(12)
.fontColor('#999999')
Text('·')
.fontSize(12)
.fontColor('#999999')
.margin({ left: 4, right: 4 })
Text(`v${doc.version}`)
.fontSize(12)
.fontColor('#999999')
Text('·')
.fontSize(12)
.fontColor('#999999')
.margin({ left: 4, right: 4 })
Text(this.formatTime(doc.lastModified))
.fontSize(12)
.fontColor('#999999')
}
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.margin({ left: 12 })
// 更多操作
Image($r('app.media.ic_more'))
.width(20).height(20)
.fillColor('#999999')
}
.width('100%')
.padding(12)
.borderRadius(12)
.backgroundColor(Color.White)
.onClick(() => {
this.openDoc(doc);
})
}
// 文档详情视图
@Builder
DocDetailView() {
Column() {
// 返回按钮和操作栏
Row() {
Image($r('app.media.ic_back'))
.width(24).height(24)
.fillColor('#333333')
.onClick(() => { this.selectedDoc = null; this.isEditing = false; })
Text(this.selectedDoc!.name)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.margin({ left: 12 })
.layoutWeight(1)
// 版本历史
Text('版本')
.fontSize(14)
.fontColor('#1565C0')
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.borderRadius(4)
.backgroundColor('#E3F2FD')
.onClick(() => {
this.versionList = this.collaboration.getVersionHistory(this.selectedDoc!.docId);
this.showVersionPanel = true;
})
// 编辑/预览切换
Text(this.isEditing ? '预览' : '编辑')
.fontSize(14)
.fontColor('#1565C0')
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.borderRadius(4)
.backgroundColor('#E3F2FD')
.margin({ left: 8 })
.onClick(() => { this.isEditing = !this.isEditing; })
}
.width('100%')
.height(56)
.padding({ left: 16, right: 16 })
.backgroundColor(Color.White)
// 文档内容区域
if (this.isEditing) {
TextArea({ text: this.docContent })
.width('100%')
.layoutWeight(1)
.fontSize(15)
.padding(16)
.onChange((value: string) => {
this.docContent = value;
})
} else {
Scroll() {
Text(this.docContent)
.fontSize(15)
.lineHeight(24)
.padding(16)
.width('100%')
}
.layoutWeight(1)
.backgroundColor(Color.White)
}
// 保存按钮
if (this.isEditing) {
Button('保存')
.width('90%')
.height(44)
.fontSize(16)
.backgroundColor('#1565C0')
.borderRadius(8)
.margin({ top: 12, bottom: 16 })
.onClick(() => this.saveDoc())
}
}
.width('100%')
.layoutWeight(1)
}
// 打开文档
private openDoc(doc: DocInfo): void {
this.selectedDoc = doc;
if (doc.type === DocType.TEXT) {
// 纯文本直接加载内容
this.docContent = '这是文档的示例内容。\n\n第一段:项目背景\n本项目旨在...\n\n第二段:技术方案\n采用鸿蒙ArkTS框架...';
this.collaboration.openDoc(doc.docId, 'current_user', this.docContent);
} else {
// 其他格式提示需要服务端转换
this.docContent = `[${doc.name}] 预览模式\n\n该文档格式需要服务端转换后预览。\n当前版本: v${doc.version}`;
}
}
// 保存文档
private saveDoc(): void {
if (!this.selectedDoc) return;
this.collaboration.saveVersion(this.selectedDoc.docId, 'current_user');
this.isEditing = false;
console.info(`[DocManage] 文档已保存: ${this.selectedDoc.name}`);
}
// ========== 辅助方法 ==========
private getDocIcon(type: DocType): Resource {
const iconMap: Record<string, Resource> = {
[DocType.PDF]: $r('app.media.ic_pdf'),
[DocType.WORD]: $r('app.media.ic_word'),
[DocType.EXCEL]: $r('app.media.ic_excel'),
[DocType.PPT]: $r('app.media.ic_ppt'),
[DocType.IMAGE]: $r('app.media.ic_image'),
[DocType.TEXT]: $r('app.media.ic_text'),
};
return iconMap[type] || $r('app.media.ic_file');
}
private getDocIconColor(type: DocType): string {
const colorMap: Record<string, string> = {
[DocType.PDF]: '#C62828',
[DocType.WORD]: '#1565C0',
[DocType.EXCEL]: '#2E7D32',
[DocType.PPT]: '#E65100',
[DocType.IMAGE]: '#6A1B9A',
[DocType.TEXT]: '#666666',
};
return colorMap[type] || '#999999';
}
private getDocBgColor(type: DocType): string {
const colorMap: Record<string, string> = {
[DocType.PDF]: '#FFEBEE',
[DocType.WORD]: '#E3F2FD',
[DocType.EXCEL]: '#E8F5E9',
[DocType.PPT]: '#FFF3E0',
[DocType.IMAGE]: '#F3E5F5',
[DocType.TEXT]: '#F5F5F5',
};
return colorMap[type] || '#F5F5F5';
}
private formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
}
private formatTime(timestamp: number): string {
const diff = Date.now() - timestamp;
if (diff < 3600000) return `${Math.floor(diff / 60000)}分钟前`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)}小时前`;
return `${Math.floor(diff / 86400000)}天前`;
}
}
踩坑与注意事项
坑1:大文件预览OOM
一个100MB的PDF,你直接加载到内存里试试?内存直接爆了。
解决方案:分页加载。PDF按页渲染,每次只加载当前页和前后各一页。图片用<Image>组件的autoResize属性,自动缩放到屏幕尺寸。
坑2:OT算法的边界情况
OT算法听起来很美,但实际场景远比教科书复杂。比如:
- 一个人在另一个人的插入位置删除内容
- 两个人同时替换同一段文字
- 格式变更和内容变更同时发生
建议:不要自己实现OT引擎。用成熟的协作引擎(如Yjs、Automerge),它们已经帮你处理了各种边界情况。实在要自己实现,至少写100个测试用例覆盖各种场景。
坑3:版本快照的存储成本
每次保存都存完整快照?一个1MB的文档改100次就是100MB。10个文档就是1GB。
解决方案:增量存储。只存差异,每10个版本存一次完整快照。恢复时找最近的完整快照,再逐步应用差异。
坑4:离线编辑的同步问题
用户在地铁上编辑了文档,到公司后连上WiFi,怎么同步?
方案:
- 离线时所有操作记录到本地
- 上线后先拉取最新版本
- 把本地操作与最新版本做diff
- 如果有冲突,弹窗让用户选择
坑5:文档权限与协作权限混淆
文档有"查看"和"编辑"权限,协作有"只读"和"可编辑"权限。这两个不是一回事——你可能对文档有编辑权限,但在当前协作会话中是只读的(因为有人在编辑)。
建议:文档权限和协作权限分开管理。文档权限是静态的(谁能看/编辑),协作权限是动态的(当前谁在编辑)。
坑6:WebView嵌入的性能问题
用WebView嵌入在线文档编辑器,功能是全了,但性能呢?加载慢、滚动卡、内存占用高,在低端设备上简直是灾难。
建议:WebView方案只用于"编辑"场景,"预览"走原生渲染。编辑时才加载WebView,预览时用轻量方案。
HarmonyOS 6适配说明
HarmonyOS 6对文档管理相关能力的增强:
- PDF原生渲染:新增
@kit.PdfKit,支持PDF原生渲染,不再依赖WebView。分页加载、文字选择、批注标注全支持。
// HarmonyOS 6 PDF原生渲染
import { pdf } from '@kit.PdfKit';
// 加载PDF文档
async function loadPdf(filePath: string): Promise<pdf.PdfDocument> {
const doc = await pdf.PdfDocument.open(filePath);
const pageCount = doc.getPageCount();
console.info(`[PDF] 总页数: ${pageCount}`);
// 渲染指定页面
const page = await doc.getPage(1);
const bitmap = await page.render({ width: 1080, height: 1920 });
return doc;
}
-
分布式文档流转:文档可以在设备间无缝流转。手机上预览,拖拽到平板上编辑,文档内容和光标位置完全保留。
-
文件管理增强:
@kit.CoreFileKit新增文件观察者能力,文档被其他协作者修改时实时收到通知,不需要轮询。 -
安全沙箱增强:企业文档和个人文档物理隔离,卸载App时企业文档自动清除,个人文档保留。
-
AI辅助写作:新增AI写作辅助能力,文档编辑时自动补全、语法检查、格式优化。
总结
文档协作的三个核心难题:预览要跨格式、协作要防冲突、版本要可追溯。预览走服务端转换+原生渲染的组合方案,协作用OT算法处理并发,版本用增量存储控制成本。
核心记住三点:
- 预览不要什么都用WebView,PDF有原生渲染,图片有Image组件,文本直接显示,WebView只用于编辑
- OT算法不要自己造轮子,用成熟的协作引擎,边界情况太多了
- 版本管理要用增量存储,每次存完整快照,存储成本会让你崩溃
| 评估维度 | 说明 |
|---|---|
| 学习难度 | ⭐⭐⭐⭐ OT算法和冲突处理需要深入理解,预览方案相对简单 |
| 使用频率 | ⭐⭐⭐⭐⭐ 企业OA必备功能,几乎每天都要用 |
| 重要程度 | ⭐⭐⭐⭐⭐ 文档协作做不好,用户直接用WPS/飞书替代 |
文档协作是OA的"高频刚需"。做不好,用户就不会用你的OA——因为文档管理是他们每天花时间最多的地方。
- 点赞
- 收藏
- 关注作者
评论(0)