鸿蒙-那些年我们踩过的坑-下

书接上回,在上一篇文章中介绍了 ForEach循环渲染和自绘制输入框遇到的坑,这里聊一下 字面量对象和类对象 以及 自定义 Dialog 的坑。

先从简单的Dialog 开始,这里没有很深入的讲解,只是一些注意点以及官方推荐用法

CustomDialogController

先说结论:在使用CustomDialogCustomDialogController做自定义弹窗时,只能作为被@Component修饰的自定义组件的成员变量,甚至可以写在组件的点击事件中,但不能写到单纯的方法中。因为它需要 UIContext 上下文

示例

正常情况:

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
55
56
57
58
59
60
61
@Entry
@Component
struct DialogControllerPage {
@State message: string = 'Hello World';
dialogID: number = 0
dialogController: CustomDialogController | null = new CustomDialogController({
builder: CustomDialogExample({
cancel: () => {
},
confirm: () => {
},
}),
})
build() {
Column() {
Text('在 Click 事件中定义').margin(10)
.fontSize(30)
.fontWeight(FontWeight.Bold)
.onClick((_) => {
let dialogController: CustomDialogController | null = new CustomDialogController({
builder: CustomDialogExample({
cancel: () => {
},
confirm: () => {
},
}),
})
dialogController.open()
})

//在自定义组件中定义
CustomDialogView()

Text('在页面中定义').margin(10)
.fontSize(30)
.fontWeight(FontWeight.Bold)
.onClick((_) => {
this.dialogController?.open()
})
}
}
}
@Component
struct CustomDialogView{
dialogController: CustomDialogController | null = new CustomDialogController({
builder: CustomDialogExample({
cancel: () => {
},
confirm: () => {
},
}),
})
build() {
Text('在自定义组件中定义').margin(10)
.fontSize(30)
.fontWeight(FontWeight.Bold)
.onClick((_) => {
this.dialogController?.open()
})
}
}

上面的这三种情况都是可以正常弹出弹窗的,但当我们把CustomDialogController写在普通方法中时

1
2
3
4
5
6
7
8
9
10
11
12
export function showDialog() {
let dialogController: CustomDialogController | null = new CustomDialogController({
builder: CustomDialogExample({
cancel: () => {
},
confirm: () => {
},
}),
})
dialogController.open()
}

这里会报一个错误,应用会崩溃,报错信息挺长的,这里截取了一部分

Pid:25224
Uid:20020185
Process name:com.huangyuanlove.arkui_demo
Process life time:47s
Reason:Signal:SIGSEGV(SEGV_MAPERR)@0x00000000000008b0 probably caused by NULL pointer dereference
Fault thread info:
Tid:25224, Name:love.arkui_demo
#00 pc 00000000029cfd70 /system/lib64/platformsdk/libace_compatible.z.so(OHOS::Ace::Framework::JSCustomDialogController::JsOpenDialog(OHOS::Ace::Framework::JsiCallbackInfo const&)+8)(1a64ce74d582cc151101042697df670d)
#01 pc 00000000009a8cb0 /system/lib64/platformsdk/libace_compatible.z.so(panda::Localpanda::JSValueRef OHOS::Ace::Framework::JsiClassOHOS::Ace::Framework::JSCustomDialogController::InternalJSMemberFunctionCallbackOHOS::Ace::Framework::JSCustomDialogController(panda::JsiRuntimeCallInfo*)+2148)(1a64ce74d582cc151101042697df670d)
#02 pc 00000000004dc50c /system/lib64/platformsdk/libark_jsruntime.so(panda::Callback::RegisterCallback(panda::ecmascript::EcmaRuntimeCallInfo*)+456)(3499a0e0c3b8b8dc50b1a4589295965e)

我想这可能就是为啥需要在@CustomDialog修饰的 struct 中声明一个CustomDialogController变量的原因。

官方推荐方案

在官方文档中有一个 不依赖UI组件的全局自定义弹窗 (推荐)。虽然说是不依赖UI组件,但实际上还是使用的UIContext这个上下文获取到promptAction,调用promptAction.openCustomDialog方法来实现的弹窗。
吐槽归吐槽,先看下用法,看完了再评价也不迟。
这里有两种方案,一种是传入ComponentContent对象,这个方案在 不依赖UI组件的全局自定义弹窗 (推荐)这里有详细介绍
另外一种方案是传入 promptAction.CustomDialogOptions,这种方案是在@ohos.promptAction (弹窗) API 参考中介绍的。

传入ComponentContent对象

创建ComponentContent对象需要一个UIContext对象,一个wrapBuilder以及wrapBuilder中需要的参数对象。

  • UIContext对象可以在页面中通过this.getUIContext()获取。
  • wrapBuilder需要一个全局被@Build修饰的方法。
1
2
3
4
5
function  glaobleConfirmOrCancelDialogBuilder1(dialogData: DialogData) {
Column() {
//这里写弹窗中的布局
}
}

然后我们可以在某个组件的点击事件中展示弹窗

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
.onClick((_) => {
let dialogData: DialogData = new DialogData()
dialogData.title = '推荐方案 一'
dialogData.message = '使用 promptAction.openCustomDialog'


let uiContext = this.getUIContext();
let promptAction = uiContext.getPromptAction();

let contentNode = new ComponentContent(uiContext, wrapBuilder(glaobleConfirmOrCancelDialogBuilder1), dialogData);
dialogData.onCancel = () => {
promptAction.closeCustomDialog(contentNode)

}
dialogData.onConfirm = () => {
promptAction.closeCustomDialog(contentNode)
}
try {
promptAction.openCustomDialog(contentNode);
} catch (error) {
let message = (error as BusinessError).message;
let code = (error as BusinessError).code;
console.error(`OpenCustomDialog args error code is ${code}, message is ${message}`);
};
})

当然,在调用openCustomDialog还有第二个可选参数promptAction.BaseDialogOptions,相应的介绍在这里

传入CustomDialogOptions

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
.onClick((_) => {
let dialogData: DialogData1 = new DialogData1()
dialogData.title = '推荐方案二'
dialogData.message = '使用 promptAction.openCustomDialog'
dialogData.onCancel = () => {
promptAction.closeCustomDialog(this.dialogID)
}
dialogData.onConfirm = () => {
promptAction.closeCustomDialog(this.dialogID)
}
this.getUIContext().getPromptAction().openCustomDialog({
builder: () => {
this.confirmOrCancelDialogBuilder1(dialogData)
},

}).then((dialogID: number) => {
this.dialogID = dialogID
})
})

这里展示弹窗的时候会返回一个dialogID,我们在关闭弹窗的时候需要传入这个id。

字面量对象与类对象

对应的英文是plain (literal) objects,class (constructor) objects,但是在不知道该怎么优雅的翻译,就先这么叫吧。
在 ArkTS 中,创建的每个字面量对象都必须有对应的类型,比如

1
2
3
let tmpUser = {
name:"123"
}

直接这么写会报错,提示:Object literal must correspond to some explicitly declared class or interface (arkts-no-untyped-obj-literals)
也就是说我们必须要先定义一个class 或者 interface,但是这里需要注意一下,我们直接使用字面量语法创建对应的class对象时,要求该class对象中不能声明方法:

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
interface UserInterface {
name: string;
}
class UserWithOutMethod{
name:string =''
}

class UserWithMethod{
name:string =''
getInfo(){
hilog.error(0x01,'UserWithMethod','getInfo')
}
}


let userInterface: UserInterface = {
name: "123"
}
let userWithOutMethod: UserWithOutMethod = {
name: "123"
}

let userWithMethod: UserWithMethod = {
name: "123",
}

userInterfaceuserWithOutMethod都是正常的,但userWithMethod会报错,提示Property 'getInfo' is missing in type '{ name: string; }' but required in type 'UserWithMethod'
字面量语法创建含有方法的对象错误信息
即使我们把这个方法补上,也是会提示错误:Object literal must correspond to some explicitly declared class or interface
字面量语法创建含有方法的对象错误信息

不过话又说回来,为啥要用字面量的语法创建类对象嘞?用new关键字它不香么?

1
let userWithMethod = new UserWithMethod()

小坑

不过对于上面包含方法的类,也有其他方案,比如通过as关键字强转

1
2
3
let userStr =  `{"name":"123"}`
let userWithMethodJSON = JSON.parse(userStr) as UserWithMethod
hilog.error(0x01,'UseASPage',userWithMethodJSON.name)

这样的话,我们是可以获取到对象的name属性,也能正常使用,
但是,不能调用这个对象的getInfo()方法,会崩溃,报错提示Error message:is not callable.
这个也挺好理解:

使用JSON.parse(userStr) as UserWithMethod这种方式得到的对象实际上是字面量对象,这个对象中并没有getInfo()方法,它的原型链上也没有这个方法,所以就会报错。

为啥 IDE 不给提示嘞?那就不知道了
当然,我们也有方法将字面量对象转为类对象,使得我们可以调用其方法:使用"class-transformer": "^0.5.1" 这个三方库,github 地址(https://github.com/typestack/class-transformer)[https://github.com/typestack/class-transformer],但要注意的是,这个库不是一个标准的ohpm库,虽然它可以在 ArkTS 里面使用。

1
2
3
4
5
import { plainToClass } from 'class-transformer';
let userStr = `{"name":"123"}`
let userWithMethodJSON = JSON.parse(userStr) as UserWithMethod
let tmp = plainToClass(UserWithMethod, userWithMethodJSON)
tmp.getInfo()

这样就正常了。

另外一个坑

还记得上一篇中提到的状态管理装饰器 @Observed装饰器和@ObjectLink装饰器:嵌套类对象属性变化么?
这里还有一个小坑,使用as强转或者使用plainToClass方法创建的对象的属性发生变化时,是无法被@ObjectLink装饰器观察到的。
举个例子,我们有一个嵌套类,使用@Observed装饰

1
2
3
4
5
6
7
8
9
10
11
@Observed
class FirstLevel {
time: number = 0
secondLevel: SecondLevel = new SecondLevel()
}

@Observed
class SecondLevel {
name: string = ''
age: number = 0
}

再定义几个赋值的方法

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
@State firstLevel?:FirstLevel = undefined
initWithNew() {
this.firstLevel = new FirstLevel()
this.firstLevel.time = systemDateTime.getTime()
let secondLevel:SecondLevel = new SecondLevel()
secondLevel.name = 'new SecondLevel'
secondLevel.age = Math.floor(Math.random() * 100)
this.firstLevel.secondLevel = secondLevel
}

initWithAs() {

let secondLevel:SecondLevel = {
name: 'as SecondLevel',
age: Math.floor(Math.random() * 100)
}
this.firstLevel = {
time:systemDateTime.getTime(),
secondLevel:secondLevel
}
}
initWithPlainToText(){
let str = `{"time":${systemDateTime.getTime()},"secondLevel":{"name":"PlainToText${Math.floor(Math.random() * 100)}","age":${Math.floor(Math.random() * 100)}}}`
let tmp:FirstLevel = JSON.parse(str) as FirstLevel
this.firstLevel = plainToClass(FirstLevel,tmp)
}

两个用于展示数据的自定义组件

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

@Component
struct ShowFistLevel{
@Watch('onFirstLevelChange') @ObjectLink firstLevel:FirstLevel
onFirstLevelChange(){
hilog.error(0x01, 'UseASPage', 'onFirstLevelChange')
}
build() {
Column(){
Text(this.firstLevel.time.toString())
ShowSecondLevel({secondLevel:this.firstLevel.secondLevel})
}.margin(15)
.backgroundColor("#e7e7e7e7")
}
}

@Component
struct ShowSecondLevel{
@Watch('onSecondLevelChange') @ObjectLink secondLevel:SecondLevel
onSecondLevelChange(){
hilog.error(0x01, 'UseASPage', 'onSecondLevelChange')
}
build() {
Column(){
Text(this.secondLevel.name)
Text(this.secondLevel.age.toString())
}.margin(15)
.backgroundColor("#e7e7e7e7")
}
}

这里需要注意的是,渲染嵌套类的组件需要和类对象的层级相同,不然也不会刷新。
比如这里FirstLevel类中有SecondLevel类型属性,就需要写成上面这样:拆成两个组件,在ShowFistLevel组件中引用ShowSecondLevel,而不能这样写

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Component
struct ShowFistLevel{
@Watch('onFirstLevelChange') @ObjectLink firstLevel:FirstLevel
onFirstLevelChange(){
hilog.error(0x01, 'UseASPage', 'onFirstLevelChange')
}
build() {
Column(){
Text(this.firstLevel.time.toString())
//这里
Text(this.firstLevel.secondLevel.name)
Text(this.firstLevel.secondLevel.age.toString())
}.margin(15)
.backgroundColor("#e7e7e7e7")
}
}

这样合并成一个组件后,其中的nameage属性发生变化时,并不能刷新页面

然后我们写个页面测试一下

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


build() {
Column() {
Row() {
Button('使用New').margin(10).onClick((_) => {
this.initWithNew()
})
Button('使用PlainToClass').margin(10).onClick((_) => {
this.initWithPlainToText()
})

Button('使用As').margin(10).onClick((_) => {
this.initWithAs()
})
}

Row() {
Button('修改time属性').margin(10).onClick((_) => {
if(this.firstLevel){
this.firstLevel.time = systemDateTime.getTime()
}
})

Button('修改 name、age 属性').margin(10).onClick((_) => {
if(this.firstLevel) {
this.firstLevel.secondLevel.name = '新名字 ' + Math.floor(Math.random() * 10)
this.firstLevel.secondLevel.age = Math.floor(Math.random() * 100)
}
})
}
if(this.firstLevel){
ShowFistLevel({firstLevel:this.firstLevel})
}

}
.height('100%')
.width('100%')
}

点击使用New后,再点击修改属性,可以看到页面刷新了
这时候点击使用PlainToClass后,页面也刷新了,但这时候点击修改time属性,页面会刷新,但点击修改 name、age 属性,页面是没有刷新的。但我们多次点击使用PlainToClass时,页面是可以刷新的。
点击使用使用As后,页面也刷新了,,但这时候点击修改time属性,页面会刷新,但点击修改 name、age 属性,页面是没有刷新的。但我们多次点击使用As时,页面是可以刷新的。

也就是说我们使用PlainToClassas 这两种方式创建出来的对象,会使得@Observed装饰器和@ObjectLink装饰器失效。这是开发过程中需要注意的。

总结

  1. 使用CustomDialogController做弹窗展示时,需要在组件中创建CustomDialogController对象,至少在 api12 上是这样的。
  2. 不想使用CustomDialogController的话,可以使用promptAction.openCustomDialog做弹窗展示,当时,它是依赖UIContext这个上下文。注意不要和Context弄混了
  3. 注意字面量对象和类对象。使用as将字面量对象转为类对象时,无法使用类本身的方法,可以使用class-transformer中的plainToClass创建类对象,这样可以调用对象的方法
  4. 使用PlainToClassas 这两种方式创建出来的对象,会使得@Observed装饰器和@ObjectLink装饰器失效。

鸿蒙-那些年我们踩过的坑-下
https://blog.huangyuanlove.com/2024/11/11/鸿蒙-那些年我们踩过的坑-下/
作者
HuangYuan_xuan
发布于
2024年11月11日
许可协议
BY HUANG兄