[Harmony] 纯血鸿蒙RichEditor填坑指南

Posted by xiuyuantech on 2025-09-27

RichEditor是支持图文混排和文本交互式编辑的组件,通常用于响应用户对图文混合内容的输入操作,例如可以输入图文的评论区。
开发者可以创建基于属性字符串进行内容管理的RichEditor组件或创建基于Span进行内容管理的RichEditor组件。
该组件从API version 10开始支持。具体用法参考 RichEditor

RichEditorController控制器

  • addTextSpan
    添加文本内容,如果组件光标闪烁,插入后光标位置更新为新插入文本的后面。

  • addImageSpan
    添加图片内容,如果组件光标闪烁,插入后光标位置更新为新插入图片的后面。
    不建议直接添加网络图片。

  • addBuilderSpan
    在RichEditor中添加用户自定义布局(BuilderSpan)。

    说明:RichEditor组件添加占位Span,占位Span调用系统的measure方法计算真实的长宽和位置。 可通过RichEditorBuilderSpanOptions设置此builder在RichEditor中的index(一个文字为一个单位)。 此占位Span不可获焦,支持拖拽,支持部分通用属性,占位、删除等能力等同于ImageSpan,长度视为一个文字。 支持通过bindSelectionMenu设置自定义菜单。 不支持通过getSpans,getSelection,onSelect,aboutToDelete获取builderSpan信息。 不支持通过updateSpanStyle,updateParagraphStyle等方式更新builder。 对此builder节点进行复制或粘贴不生效。 builder的布局约束由RichEditor传入,如果builder里最外层组件不设置大小,则会用RichEditor的大小作为maxSize。 builder的手势相关事件机制与通用手势事件相同,如果builder中未设置透传,则仅有builder中的子组件响应。 如果组件光标闪烁,插入后光标位置更新为新插入builder的后面。

  • addSymbolSpan
    在RichEditor中添加图标小符号(SymbolSpan),如果组件光标闪烁,插入后光标位置更新为新插入SymbolSpan的后面。暂不支持手势、复制、拖拽处理。

自定义BuidlerSpan填坑

通过addBuilderSpan接口添加的自定义布局Span,getSpans、onWillChange等API不会返回BuilderSpan内部的信息。开发者需要自行维护BuilderSpan的状态,并且在组件内容发生变化时同步更新。
官方示例:https://developer.huawei.com/consumer/cn/doc/harmonyos-references/ts-basic-components-richeditor#示例10使用和管理组件内的builderspan
官方组件默认只支持TextSpan、ImageSpan、SymbolSpan返回信息,通过addBuilderSpan接口添加的无法获取。示例中代码逻辑比较复杂,经过多方调研想到了扩展RichEditorController控制器的方法,可以完全根据自己的业务来自定义。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
import { componentUtils } from '@kit.ArkUI' 
interface CustomSpanInfo {
id: string
componentInfo: componentUtils.ComponentInfo
}
const BUILDERSPAN_ID = 'customSpanId'
export class MyRichEditorController extends RichEditorController {
private customBuilderIds: string[] = []
generateBuilderId() { return `customSpan${this.getCustomBuilderSize()}` }
getCustomBuilderSize() { return this.customBuilderIds.length }
addCustomBuilderSpan(id: string, value: CustomBuilder, options?: RichEditorBuilderSpanOptions | undefined): number {
this.customBuilderIds.push(id)
return super.addBuilderSpan(value, options)
}
getSpans(value?: RichEditorRange | undefined): (RichEditorImageSpanResult | RichEditorTextSpanResult)[] {
let customSpanInfos: CustomSpanInfo[] = []
for (let id of this.customBuilderIds) {
let componentInfo = componentUtils.getRectangleById(id)
if (!componentInfo) { continue } // 宽高为0代表已经被删除了
if (componentInfo.size.width == 0 && componentInfo.size.height == 0) {
continue
}
customSpanInfos.push({ id, componentInfo }) } // 根据组件位置排序
customSpanInfos = customSpanInfos.sort((a, b) => {
if (a.componentInfo.windowOffset.y >= b.componentInfo.windowOffset.y + b.componentInfo.size.height) {
return 1
}
if (a.componentInfo.windowOffset.y + a.componentInfo.size.height <= b.componentInfo.windowOffset.y) {
return -1
}
return a.componentInfo.windowOffset.x - b.componentInfo.windowOffset.x
}) // 按序替换得到最终结果
let spans = super.getSpans(value)
let index = 0
for (let span of spans) {
if (this.isCustomSpan(span)) {
span[BUILDERSPAN_ID] = customSpanInfos[index++].id
}
}
return spans
}
/** 判断是否CustomSpan */
isCustomSpan(span: RichEditorTextSpanResult | RichEditorImageSpanResult): boolean {
return span['imageStyle'] != undefined && (span['valueResourceStr'] == '' || span['valueResourceStr'] == ' ')
}
/** 获取CustomSpanId */
getCustomSpanId(span: RichEditorTextSpanResult | RichEditorImageSpanResult): string | undefined {
return span[BUILDERSPAN_ID]
}

/** 清空 */ clear() {
this.customBuilderIds = [] this.deleteSpans()
}
}

getSpan填坑

getSpan是用于获取span信息。参数是start和end即起始位置和结束位置,最好都为-1则返回所有span,若end使用getCaretOffset则只会返回到当前光标所在位置。

上传文件填坑

一般上传文件代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const formData = new FormData();
formData.append('file', this.selectedFile);

axios.post('YOUR_UPLOAD_URL', formData, {
headers: {
'Content-Type': 'multipart/form-data'
// 注意:通常不需要手动设置Content-Type,因为浏览器会自动设置
// 如果后端有特殊需求,请根据实际情况调整
}
})
.then(response => {
this.uploadStatus = '文件上传成功';
// 处理上传成功后的逻辑,如更新UI、发送通知等
})
.catch(error => {
this.uploadStatus = '文件上传失败: ' + error.message;
// 处理上传失败的情况
});

但是鸿蒙却不支持只有,上传上去后端却说什么也没有收到而且当前版本只支持 Stage 模型。具体参考 OpenHarmony-SIG / ohos_axios

鸿蒙代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
const formData = new FormData();
let selectedFile = fs.openSync(path,fs.OpenMode.READ_ONLY)
//文件相关api尽量是文件描述符,使用文件对象可能发生异常
let stat = fs.lstatSync(selectedFile.path)
let buffer = new ArrayBuffer(stat.size)
fs.readSync(selectedFile.fd,buffer)
fs.fsyncSync(selectedFile.fd)
formData.append('files', buffer,{
filename:selectedFile.name,
type: 'image/png' //必须加类型,否则后台打不开文件
})
fs.closeSync(selectedFile.fd)
axios.post('YOUR_UPLOAD_URL', formData, {
headers: {
'Content-Type': 'multipart/form-data'
// 注意:通常不需要手动设置Content-Type,因为浏览器会自动设置
// 如果后端有特殊需求,请根据实际情况调整
}
})
.then(response => {
this.uploadStatus = '文件上传成功';
// 处理上传成功后的逻辑,如更新UI、发送通知等
})
.catch(error => {
this.uploadStatus = '文件上传失败: ' + error.message;
// 处理上传失败的情况
});

文件相关api尽量是文件描述符,使用文件对象可能发生异常。添加文件时必须传文件类型,否则无法查看文件。

其他避坑指南

嵌套类数据更新UI不更新怎么办?
正常按照官网来的话使用v1和v2状态管理基本上没啥问题,涉及到复杂逻辑带有嵌套类的话可能会遇到UI不更新的问题,原因是通过服务端返回json解析而来的实体类会无效。
解决办法如下:

1
2
3
4
5
6
7
8
9
10
11
// 鸿蒙还提供了UIUtils.makeObserved()等方法来实现数据观察更新
axios.create({
...
transformResponse:[(data:object):object = >{
try{
UIUtils.makeObserved(JSON.parse(data)) //关键代码
}catch(err){
return UIUtils.makeObserved(data)
}
}]
})

鸿蒙打包hvigorw clean报错No npmrc file is matched in the current user folder解决?

1
2
registry=https://repo.huaweicloud.com/repository/npm/
@ohos:registry=https://repo.harmonyos.com/npm/

Image组件无法响应长按事件?

组件默认拖拽效果,设置为true时,组件可拖拽,绑定的长按手势不生效。API version 9及之前,默认值为false。API version
10及之后,默认值为true。

1
2
Image()
.draggable(fasle)

@LocalBuilder和@Builder区别?

@Builder:动态绑定到当前组件上下文(通常是子组件),传递到子组件时函数内部this指向子组件实例;可以使用wrapBuilder包裹全局Buidler,通过对象封装避免上下文丢失。
@LocalBuilder:保留原始父组件上下文,传递到子组件时函数内this仍指向父组件实例‌。

安全区域使用expandSafeArea属性无效?

最好同时设置固定宽高和expandSafeArea属性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Entry
@Component
struct SafeAreaExample1 {
@State text: string = ''
controller: TextInputController = new TextInputController()

build() {
Row() {
Column()
.height('100%').width('100%')
.backgroundImage($r('app.media.bg')).backgroundImageSize(ImageSize.Cover)
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
}.height('100%')
}
}

跨平台路由管理?

推荐使用@ohos/router

状态数据监听?

推荐使用@Observe/@ObserveV2/UIUtils.makeObserved

常见问题, 行业实践与常见问题