1 关键字

@Link;CustomDialogController

2 问题描述

能复现问题的代码如下(3.1release及截至当前[2022/10/8] master版本可复现):

@CustomDialog
struct MyDialog {
  private controller: CustomDialogController
  @Link count: number
  build() {
    Row() {
      Button("Change").fontSize(32)
        .onClick(() => {
          this.count = Math.random();
        })
    }
  }
}
​
@Preview
@Entry
@Component
struct Index {
  @State private count: number = 10;
  dialogController: CustomDialogController = new CustomDialogController({
    builder: MyDialog({ count: $count })
  })
  build() {
    Column() {
      Text(`${this.count}`).fontSize(82)
      Text(`OpenDialog`)
        .fontSize(50).onClick(() => {
        this.dialogController.open()
      })
    }
  }
}

测试步骤:

  1. 点击'OpenDialog',弹出自定义弹窗

  2. 点击自定义弹窗里面的'Change'超过2次

Index组件里count变量对应展示的数字只变化了2次,第3次及后续点击数字不再变化

3 问题原因

3.1 正常机制

点击自定义弹窗里面的'Change'每次点击count变化会变化为一个新的随机数,由@Link装饰的变量可以和父组件的@State变量建立双向数据绑定,父组件Index展示新的随机数

3.2 异常机制

父组件Index只在前面2次点击后展示新的随机数

4 解决方案

使用自定义组件实现类似弹窗效果,避免使用CustomDialogController实现带有与其他组件实现数据交互的弹窗效果.

5 定位过程

eTS的声明式开发范式下,组件最终都继承自NativeView,源码:\foundation\ace\ace_engine\frameworks\bridge\declarative_frontend\jsview\js_view.cpp NativeView的相关实现类里中以下两个成员customViewChildren和lastAccessedViewIds,分表表示:存储组件内子级组件的unordered_map(子组件id为key),存储组件内仍然处于可关联状态的子级组件的id的unordered_set。

// hold handle to the native and javascript object to keep them alive
// until they are abandoned
std::unordered_map<std::string, JSRef<JSObject>> customViewChildren_;
// a set of valid viewids on a renderfuntion excution
// its cleared after cleaning up the abandoned child.
std::unordered_set<std::string> lastAccessedViewIds_;

因数据变化界面需要刷新界面时,会执行到 JSView::InternalRender,这个方法会执行 JSView::CleanUpAbandonedChild,主要逻辑用来对一些已经不再被其他组件关联的组件进行Destroy操作,并且清空lastAccessedViewIds_:

void JSView::CleanUpAbandonedChild()
{
    auto startIter = customViewChildren_.begin();
    auto endIter = customViewChildren_.end();
    while (startIter != endIter) {
        //遍历查找customViewChildren_,是否存在于lastAccessedViewIds_
        auto found = lastAccessedViewIds_.find(startIter->first);
        if (found == lastAccessedViewIds_.end()) {
            //如果lastAccessedViewIds_里找不到节点id,则执行view->Destroy
            removedViewIds.emplace_back(startIter->first);
            auto* view = startIter->second->Unwrap<JSView>();
            if (view != nullptr) {
                view->Destroy(this);
            }
        }
        ++startIter;
    }
    ...
    //清空lastAccessedViewIds_
    lastAccessedViewIds_.clear();
}

对应到示例上,当第一次点击自定义弹窗内的的'Change'时,执行到CleanUpAbandonedChild,在代码的最后清空了lastAccessedViewIds。第二次点击时,执行到CleanUpAbandonedChild方法的while循环内,此时在lastAccessedViewIds内已经查询不到自定义弹窗组件的对应id,执行view->Destroy(this);:

void JSView::Destroy(JSView* parentCustomView)
{
    ...
    {
        ACE_SCORING_EVENT("Component[" + viewId_ + "].AboutToBeDeleted");
        jsViewFunction_->ExecuteAboutToBeDeleted(); //执行组件view内的aboutToDisappear函数
    }
}

执行自定义弹窗view内的aboutToDisappear函数,这个函数在ets编译后的js文件内定义如下:

aboutToBeDeleted() {
    this.__count.aboutToBeDeleted();
    SubscriberManager.Get().delete(this.id());
}

此处涉及到ETS状态管理部分,执行的大致逻辑如下: 1:SubscriberManager '订阅器总管'删除了弹窗view的id 2:弹窗view内基于count的SynchedPropertySimpleTwoWay变量的id也从'订阅器总管'中删除了

这意味着: 1:此后弹窗view内的count变量的变化不再通知到父级组件 2:父级组件通过@link关联的count变量的变化不再通知到弹窗view

class SynchedPropertySimpleTwoWay<T> extends ObservedPropertySimpleAbstract<T>
  //source_解除关联id,@link不再生效,状态变化source_与SynchedPropertySimpleTwoWay之间不再相互通知
  aboutToBeDeleted() {    
    this.source_.unlinkSuscriber(this.id__());
    this.source_ = undefined;
    super.aboutToBeDeleted();
  }

即第3次点击后父组件界面已经不会再刷新

但普通的自定义组件不会产生此现象,原因在于ETS对于普通组件编译后的js文件形式如下:

render() {
    Column.create();
    let earlierCreatedChild_2 = this.findChildById("2");

状态变化致render执行时,通过这个findChildById,最终把普通的自定义组件的viewId添加到了lastAccessedViewIds_:

void JSView::FindChildById(const JSCallbackInfo& info)
{
    std::string viewId = info[0]->ToString();
    info.SetReturnValue(GetChildById(viewId)); //执行GetChildById
}
​
JSRef<JSObject> JSView::GetChildById(const std::string& viewId)
{
    auto id = ViewStackProcessor::GetInstance()->ProcessViewId(viewId);
    auto found = customViewChildren_.find(id);
    if (found != customViewChildren_.end()) {
        ChildAccessedById(id); //执行ChildAccessedById
        ...
}
​
void JSView::ChildAccessedById(const std::string& viewId)
{   
    //lastAccessedViewIds_添加viewId
    lastAccessedViewIds_.emplace(viewId);
}

如此在CleanUpAbandonedChild方法内,不会执行到view->Destroy(this);,不会致使@link失效。 但是对于CustomDialogController自定义弹窗,编译后的js文件里,并没有对应代码最终能实现JSview更新lastAccessedViewIds_的机制。

 

 

首先从现象上看非常类似组件被'销毁',通过应用内添加日志验证这一猜测: 应用ETS通过打印 SubscriberManager.INSTANCE.subscriberById查看数据变化时对应ID的变化:

 Button("Change").fontSize(32)
        .onClick(() => {
          for (let [id, subscriber] of SubscriberManager.INSTANCE_.subscriberById_) {
            // @ts-ignore
            console.log(`Id: ${id} -> ${subscriber['info'] ? subscriber['info']() : 'view'}`);
          }
          this.count = Math.random();
        })

LOG:

//第一次点击
[default][Console   DEBUG]    app Log: Id: 0 -> view
[default][Console   DEBUG]    app Log: Id: 1 -> count
[default][Console   DEBUG]    app Log: Id: 2 -> view
[default][Console   DEBUG]    app Log: Id: 3 -> count
​
//第二次点击
[default][Console   DEBUG]    app Log: Id: 0 -> view
[default][Console   DEBUG]    app Log: Id: 1 -> count
[default][Console   DEBUG]    app Log: Id: 2 -> view
[default][Console   DEBUG]    app Log: Id: 3 -> count
​
//第三次点击
[default][Console   DEBUG]    app Log: Id: 0 -> view
[default][Console   DEBUG]    app Log: Id: 1 -> count

即view类型的id在第三次少了ID为2的view。最有可能的是aboutToBeDeleted,进而通过添加底层源码日志(不再详细列举)编译进一步验证,从而缩小排查引起问题出现的原因范围。

6 知识分享

@link装饰的变量与其父组件中的数据共享相同的值,父子组件双向同步。子组件被@link装饰的变量与其父组件中对应的数据建立双向数据绑定

Logo

社区规范:仅讨论OpenHarmony相关问题。

更多推荐