使用Flutter + V8/JsCore开发小程序引擎(三)

小程序引擎之--UI树与局部刷新

本章内容介绍小程序页面构造的树结构及调用this.setData()如何进行局部刷新

1 页面结构

1.1 首先,我们来看一个简单的页面布局以及对应的代码

image
  • html代码
<!DOCTYPE html>
<html lang="en" html-identify="CC">
<head>
    <meta charset="UTF-8" />
    <style type="text/css" media="screen">
        @import "example.css";
    </style>
</head>
<body>
    <singlechildscrollview>
        <column>
            <container id="item-container" style="color: {{color1}};">
                <text style="font-size: 14px; color: white;">文本1文本1文本1文本1文本1文本1文本1文本1文本1文本1文本1</text>
            </container>
            <container id="item-container" style="color: {{color2}};">
                <text style="font-size: 14px; color: white;">文本2</text>
            </container>
            <container id="item-container" style="color: {{color3}};">
                <text style="font-size: 14px; color: white;">文本3</text>
            </container>
            <container id="item-container" style="color: yellow;">
                <raisedbutton style="color: green;" bindtap="onclick">
                    <text style="font-size: 14px;color: white;">修改颜色</text>
                </raisedbutton> 
            </container>
        </column>
    </singlechildscrollview>
</body>
</html>
  • css代码
.item-container {
    height: 150;
    margin-top:10;
    margin-left: 10; 
    margin-right: 10;
    padding:10;
}
  • js代码
Page({
    data: {
        color1: "red",
        color2: "green",
        color3: "blue",
    },
    onclick() {
        var result = this.data.color1 === "black" ? "green" : "black";
        this.setData({
            color1: result,
            color2: result,
            color3: result
        });
    },    
    onLoad(e) {
        
    },
    onUnload() {

    }
});

1.2 转换成的json

{
    "style": {
        ".item-container": {
            "height": "150",
            "margin-top": "10",
            "margin-left": "10",
            "margin-right": "10",
            "padding": "10"
        }
    },
    "body": {
        "tag": "body",
        "innerHTML": "",
        "childNodes": [
            {
                "tag": "singlechildscrollview",
                "innerHTML": "",
                "childNodes": [
                    {
                        "tag": "column",
                        "innerHTML": "",
                        "childNodes": [
                            {
                                "tag": "container",
                                "innerHTML": "",
                                "childNodes": [
                                    {
                                        "tag": "text",
                                        "innerHTML": "5paH5pysMeaWh+acrDHmlofmnKwx5paH5pysMeaWh+acrDHmlofmnKwx5paH5pysMeaWh+acrDHmlofmnKwx5paH5pysMeaWh+acrDE=",
                                        "childNodes": [],
                                        "datasets": {},
                                        "events": {},
                                        "directives": {},
                                        "attribStyle": {
                                            "font-size": "14px",
                                            "color": "white"
                                        },
                                        "attrib": {}
                                    }
                                ],
                                "datasets": {},
                                "events": {},
                                "directives": {},
                                "attribStyle": {
                                    "color": "{{color1}}"
                                },
                                "attrib": {},
                                "id": "item-container"
                            },
                            ... 此除省略部分json
                        ],
                        "datasets": {},
                        "events": {},
                        "directives": {},
                        "attribStyle": {},
                        "attrib": {}
                    }
                ],
                "datasets": {},
                "events": {},
                "directives": {},
                "attribStyle": {},
                "attrib": {}
            }
        ],
        "datasets": {},
        "events": {},
        "directives": {},
        "attribStyle": {},
        "attrib": {}
    },
    "script": "IWZ1bmN0aW9uKGUpe3ZhciByPXt9O2Z1bmN0aW9uIHQobyl7aWYocltvXSlyZXR1cm4gcltvXS5leHBvcnRzO3ZhciBuPXJbb109e2k6byxsOiExLGV4cG9ydHM6e319O3JldHVybiBlW29dLmNhbGwobi5leHBvcnRzLG4sbi5leHBvcnRzLHQpLG4ubD0hMCxuLmV4cG9ydHN9dC5tPWUsdC5jPXIsdC5kPWZ1bmN0aW9uKGUscixvKXt0Lm8oZSxyKXx8T2JqZWN0LmRlZmluZVByb3BlcnR5KGUscix7ZW51bWVyYWJsZTohMCxnZXQ6b30pfSx0LnI9ZnVuY3Rpb24oZSl7InVuZGVmaW5lZCIhPXR5cGVvZiBTeW1ib2wmJlN5bWJvbC50b1N0cmluZ1RhZyYmT2JqZWN0LmRlZmluZVByb3BlcnR5KGUsU3ltYm9sLnRvU3RyaW5nVGFnLHt2YWx1ZToiTW9kdWxlIn0pLE9iamVjdC5kZWZpbmVQcm9wZXJ0eShlLCJfX2VzTW9kdWxlIix7dmFsdWU6ITB9KX0sdC50PWZ1bmN0aW9uKGUscil7aWYoMSZyJiYoZT10KGUpKSw4JnIpcmV0dXJuIGU7aWYoNCZyJiYib2JqZWN0Ij09dHlwZW9mIGUmJmUmJmUuX19lc01vZHVsZSlyZXR1cm4gZTt2YXIgbz1PYmplY3QuY3JlYXRlKG51bGwpO2lmKHQucihvKSxPYmplY3QuZGVmaW5lUHJvcGVydHkobywiZGVmYXVsdCIse2VudW1lcmFibGU6ITAsdmFsdWU6ZX0pLDImciYmInN0cmluZyIhPXR5cGVvZiBlKWZvcih2YXIgbiBpbiBlKXQuZChvLG4sZnVuY3Rpb24ocil7cmV0dXJuIGVbcl19LmJpbmQobnVsbCxuKSk7cmV0dXJuIG99LHQubj1mdW5jdGlvbihlKXt2YXIgcj1lJiZlLl9fZXNNb2R1bGU/ZnVuY3Rpb24oKXtyZXR1cm4gZS5kZWZhdWx0fTpmdW5jdGlvbigpe3JldHVybiBlfTtyZXR1cm4gdC5kKHIsImEiLHIpLHJ9LHQubz1mdW5jdGlvbihlLHIpe3JldHVybiBPYmplY3QucHJvdG90eXBlLmhhc093blByb3BlcnR5LmNhbGwoZSxyKX0sdC5wPSIiLHQodC5zPTApfShbZnVuY3Rpb24oZSxyKXtQYWdlKHtkYXRhOntjb2xvcjE6InJlZCIsY29sb3IyOiJncmVlbiIsY29sb3IzOiJibHVlIn0sb25jbGljaygpe3ZhciBlPSJibGFjayI9PT10aGlzLmRhdGEuY29sb3IxPyJncmVlbiI6ImJsYWNrIjt0aGlzLnNldERhdGEoe2NvbG9yMTplLGNvbG9yMjplLGNvbG9yMzplfSl9LG9uTG9hZChlKXt9LG9uVW5sb2FkKCl7fX0pfV0pOwovLyMgc291cmNlTWFwcGluZ1VSTD1leGFtcGxlLmJ1bmRsZS5qcy5tYXA=",
    "config": {
        "navigationBarTitleText": "",
        "backgroundColor": "#eeeeee",
        "enablePullDownRefresh": true
    }
}

1.3 对应的页面树结构图

image

1.4 在flutter中对应的树结构

从下面图片我们可以看到,绿色框标出的就是我们在html里面写的标签组件,那么红色框里面的是什么呢?这个稍后我们介绍如何进行局部刷新会做详细说明。

image

2 页面刷新

  • 先看下效果图
image
  • 代码解析

点击“修改颜色”按钮触发onclick函数回调,通过this.setData()修改数据并触发页面刷新

onclick() {
    var result = this.data.color1 === "black" ? "green" : "black";
    this.setData({
        color1: result,
        color2: result,
        color3: result
    });
}

3 局部刷新

我们先思考下,怎么样做到局部刷新呢?

  • 从上面flutter中对应的树结构图知道,目前我们用到的组件SingleChildScrollView、Container、Text等等这些组件在 flutter 中都是 StatelessWidget,也就意味着我们不能直接对其进行刷新。

  • 第一个想法是不是可以
    把所有的StatelessWidget组件都套一层,都继承StatefulWidget,那么就可以进行刷新,但是经过一番试验过后发现, StatefulWidget的组件在build之后,当前的_state会被赋值为null,所以不能通过外部保存state来进行刷新,除非每一个组件都赋值一个GlobalKey,通过全局保存state实例来进行刷新,但是这种方式官方不推荐,GlobalKey资源稀缺,所以这种方式行不通。 (ps : 代码如下)

class ContainerStateful extends StatefulWidget {
  ContainerStateful(this._child) {}
  @override
  State<StatefulWidget> createState() {
    return _ContainerState();
  }
}

class _ContainerState extends State<ContainerStateful> {
  _ContainerState(Widget child) {
  }
  @override
  Widget build(BuildContext context) {
    return Container(child: _child);
  }
}
  • 换一种方式,官方提供了一种刷新StatelessWidget方式,通过ValueListenableBuilder来做刷新,这个就是我们上面flutter中对应的树结构图里面红框标出的内容。在对应需要修改的属性套一层ValueListenableBuilder,通过保存其实例,对其value进行修改赋值,就可以触发对StatelessWidget进行刷新。
  • 虽然有了刷新方案,但是同样问题来了,我们是否对每个组件的属性都套一层ValueListenableBuilder来做监听修改呢?显然不太实际,因为每个组件的属性太多了,如果每个都手动做监听,那么代码量将非常大,这里我想了一个方案,只对child(一些组件是children)进行监听修改,也就是说当检查组件有属性变化,我们是找到对应的父组件,对齐child(或者children)进行替换来达到刷新效果。(ps : 代码如下)
class ContainerStateless extends BaseWidget {
    ValueNotifier<List<BaseWidget>> children;
  ContainerStateless(BaseWidget parent, ...) {
    this.parent = parent;
    this.children = children;
    ...
  }
  @override
  Widget build(BuildContext context) {
    ...
    return Container(
       ...
        child: ValueListenableBuilder(
            builder:
                (BuildContext context, List<BaseWidget> value, Widget child) {
              return value.length > 0 ? value[0] : null;
            },
            valueListenable: children));
  }
}
  • 既然方案有了,我们如果刷新呢?请继续往下看。

3.1 第一种方式

这种方式比较简单粗暴,每次点击“修改颜色”按钮,我们直接生成一颗新的UI数,直接遍历对比两棵新旧UI树,检查节点每个属性是否发生变化,发生变化就对其父节点的children进行替换。

时间复杂度O(N)、空间复杂度O(N),N为Component节点数

  • 图解


    image
  • 代码

void compareTreeAndUpdate(BaseWidget oldOne, BaseWidget newOne) {
    var same = true;
    if (oldOne.component.tag != newOne.component.tag) {
      if (null != oldOne.parent) {
        same = false;
      } else {
        same = false;
      }
    } else {
      oldOne.component.properties.forEach((k, v) {
        if (!newOne.component.properties.containsKey(k)) {
          same = false;
        } else if (newOne.component.properties[k].getValue() != v.getValue()) {
          same = false;
        }
      });

      if (oldOne.children.value.length != newOne.children.value.length) {
        same = false;
      }

      if (oldOne.component.innerHTML.getValue() != newOne.component.innerHTML.getValue()) {
        same = false;
      }
    }
    if (same) {
      for (var i = 0; i < oldOne.children.value.length; i++) {
        compareTreeAndUpdate(oldOne.children.value[i], newOne.children.value[i]);
      }
    } else {
      oldOne.updateChildrenOfParent(newOne.parent.children);
    }
  }
abstract class BaseWidget extends StatelessWidget {
  String pageId;
  Component component;
  MethodChannel methodChannel;
  BaseWidget parent;
  ValueNotifier<List<BaseWidget>> children;

  void setChildren(ValueNotifier<List<BaseWidget>> children) {
    this.children = children;
  }

  void updateChildrenOfParent(ValueNotifier<List<BaseWidget>> newChildren) {
    if (null != parent && parent.children.value != newChildren.value) {
      newChildren.value.forEach((it) {
        it.parent = parent;
      });
      parent.children.value = newChildren.value;
    }
  }
}

3.2 第二种方式

单点更新,不重新生成新的Component Tree 跟 Widget Tree,也不进行整棵树遍历,具体实现如下

  • 增加一个js表达式变量监听,变量改动触发更新
  • 收集所有节点存入map中,通过id作为key进行存储
  • 难点问题,for(复制)出来的组件处理

时间复杂度O(1)、空间复杂度O(N),N为Component节点数

  • js变量监听
 /**
 * 观察者,用于观察data对象属性变化
 * @param data
 * @constructor
 */
class Observer {

    constructor() {
        this.currentWatcher = undefined;
        this.collectors = [];
        this.watchers = {};
        this.assembler = new Assembler();
    }

    /**
     * 将data的属性变成可响应对象,为了监听变化回调
     * @param data
     */
    observe(data) {
        if (!data || data === undefined || typeof (data) !== "object") {
            return;
        }
        for (const key in data) {
            let value = data[key];
            if (value === undefined) {
                continue;
            }
            this.defineReactive(data, key, value);
        }
    }

    defineReactive(data, key, val) {
        const property = Object.getOwnPropertyDescriptor(data, key);
        if (property && property.configurable === false) {
            return
        }
        const getter = property && property.get;
        const setter = property && property.set;
        if ((!getter || setter) && arguments.length === 2) {
            val = data[key];
        }

        let that = this;
        let collector = new WatcherCollector(that);
        this.collectors.push(collector);

        Object.defineProperty(data, key, {
            enumerable: true,
            configurable: true,
            get: function reactiveGetter() {
                const value = getter ? getter.call(data) : val;
                // 在这里将data的数据与对应的watcher进行关联
                if (that.currentWatcher) {
                    collector.addWatcher(that.currentWatcher);
                }
                return value;
            },
            set: function reactiveSetter(newVal) {
                const value = getter ? getter.call(data) : val;
                if (newVal === value || (newVal !== newVal && value !== value)) {
                    return;
                }
                if (setter) {
                    setter.call(data, newVal);
                } else {
                    val = newVal;
                }
                collector.notify(data);
            }
        });
    }

    addWatcher(watcher) {
        if (this.watchers[watcher.id] === undefined) {
            this.watchers[watcher.id] = [];
        }
        this.watchers[watcher.id].push(watcher);
    }

    removeWatcher(ids) {
        if (ids) {
            let keys = [];
            ids.forEach((id) => {
                if (this.watchers[id]) {
                    this.watchers[id].forEach((watcher) => {
                        keys.push(watcher.key());
                    });
                    this.watchers[id] = undefined;
                }
            });
            if (this.collectors) {
                this.collectors.forEach((collector) => {
                    keys.forEach((key) => {
                        collector.removeWatcher(key)
                    });
                });
            }
        }
    }
}
  • 有了监听后,我们调用this.setData()收集到的变动如下:
[
    {
        "id":"container-397771684",
        "type":"property",
        "key":"color",
        "value":"black"
    },
    {
        "id":"container-328264404",
        "type":"property",
        "key":"color",
        "value":"black"
    },
    {
        "id":"container-416353772",
        "type":"property",
        "key":"color",
        "value":"black"
    }
]
  • 那么有了组件id跟变更属性内容,我们就可以单点更新了

上面我们提到,我们实现局部刷新的方式是更新child(children)节点,在其上面包装一层ValueListenableBuilder,那么现在我们要单点更新某个属性,我们将在整个widget外层包装一层ValueListenableBuilder,将其属性跟child(children)封装到一个监听变量Data中:

  • Data代码
class Data {

  Map<String, Property> map;
  List<BaseWidget> children;

  Data(this.map);

}
  • Container Widget代码
class ContainerStateless extends BaseWidget {
  ContainerStateless(
      BaseWidget parent,
      String pageId,
      MethodChannel methodChannel,
      Component component) {
    this.parent = parent;
    this.pageId = pageId;
    this.methodChannel = methodChannel;
    this.component = component;
    this.data = ValueNotifier(Data(component.properties));
  }

  @override
  Widget build(BuildContext context) {

    return ValueListenableBuilder(
        builder: (BuildContext context, Data data, Widget child) {

          var alignment = MAlignment.parse(data.map['alignment'],
              defaultValue: Alignment.topLeft);

          return Container(
              key: ObjectKey(component),
              alignment: alignment,
              color: MColor.parse(data.map['color']),
              width: MDouble.parse(data.map['width']),
              height: MDouble.parse(data.map['height']),
              margin: MMargin.parse(data.map),
              padding: MPadding.parse(data.map),
              child: data.children.isNotEmpty ? data.children[0] : null);
        },
        valueListenable: this.data);
  }
}

每个map里面的属性或者child(children)发生变化都会触发重新build一个widget,component是不变的,由于key的关系,所以会复用之前的widget,不用担心性能消耗。来看下刷新的帧率跟耗时:

image
  • 难点问题,for(复制)出来的组件处理,这部分比较复杂,有兴趣的同学去看下源码

《使用Flutter + V8/JsCore开发小程序引擎(一)》

《使用Flutter + V8/JsCore开发小程序引擎(二)》

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,088评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,715评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,361评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,099评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 60,987评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,063评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,486评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,175评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,440评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,518评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,305评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,190评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,550评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,880评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,152评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,451评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,637评论 2 335