-

前言

在工程宣布下一步兼容iOS 13+以上平台时,想到可以放开手脚使用 NSDiffableDataSourceSnapshot 了,

DiffDataSource本质是对Data和UI的操作封装,正好组内融合跨平台技术方案依赖,对Flutter、arkTs的学习正好让我做个阶段性的对比总结。

那么更进一步说,DiffDataSource造成的UI操作方式的变更,会不会引起对UI曝光/消失方式的变更呢?

本文基于如上的思考,按照如下流程进行阐述:

一、iOS DiffDataSource 是否应该沉淀成最佳实践?

二、Flutter Diff方案如何实现?

三、Flutter widget曝光/消失的监听方案

———>

一、iOS DiffDataSource 是否应该沉淀成最佳实践?

(一)传统手艺

相信稍微有点工作经历的iOSer,都会在使用 UITableView 时经历过如下的crash报错堆栈:

Terminating app due to uncaught exception ‘NSInternalInconsistencyException’, reason: ‘Invalid update: invalid number of sections. The number of sections contained in the table view after the update (3) must be equal to the number of sections contained in the table view before the update (3), plus or minus the number of sections inserted or deleted (1 inserted, 0 deleted).’

与此对应的,我们使用的方案实现也是传统的UITableView DataSource的方案,即:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#pragma mark - Table View

- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView {
return 1;
}


- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
return self.objects.count;
}


- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"Cell" forIndexPath:indexPath];

NSDate *object = self.objects[indexPath.row];
cell.textLabel.text = [object description];
return cell;
}

这种方案的弊端非常明显,尤其是在操作数据源和操作UI不匹配时,非常容易出现crash。

且”操作数据源”这一行为大都是在ViewModel进行,很难从代码层面上进行统一的管理。

那么有没有办法统一进行管理?当然有,在NSDiffableDataSourceSnapshot诞生前,IGListKit提供了一个比较简单的方案。

依旧是互联网通用的解决问题的方法论:当一个问题解决不了时,那就加中间层。

(二)NSDiffDataSource算不算最佳实践?

iOS 13推出的NSDiffableDataSourceSnapshot的使用想必大家都比较熟了,具体使用就不介绍了,

这里我们要讨论的一个topic是: NSDiffDataSource 算不算最佳实践?以后不用 NSDiffDataSource 的TableView算不算有问题?

选择新方案的理由比迁移旧会简单些,只要新方案能简单高效的完成旧方案的能力,我们就认为它是值得推广的。

截止到目前为止,业内大家并没有遇到哪些之前用 UITableView 旧接口实现的方法,在 NSDiffDataSource 中实现不了的。

所以,我们可以得出结论: NSDiffDataSource 相比旧的 UITableView 方案,算是最佳实践,应该朝着这个方向去推动。

(三)使用NSDiffDataSource后,cell的曝光/消失接口如何获取?

UITableView初始化时,需要传入的是两类delete: .dataSource 和 .delegate。

NSDiffDataSource 替换的是 .dataSource 方案,willDisplay和endDisplay等接口,还是使用原有回调即可实现。

二、Flutter Diff方案如何实现?

上面聊完iOS的DiffDataSource的方案,我们来看看Flutter这类响应式编程的Diff方案,

首先我贴一张我写的一个Flutter Demo示意图,接下来我们就围绕这个产品形态聊技术方案:

Flutter对于可变数据源有一个比较好听的名字:stream,其匹配着 StreamController 用于管理。

所以Flutter中DiffDataSource的封装也比较简单,使用 StreamBuilder 构建widget,使用 StreamController 进行管理。

StreamController 是一个流控制器,用于管理和控制数据流(Stream)。

组成部分:

  • sink:用于向流中添加数据的入口(生产者)。
  • stream:用于监听数据的出口(消费者)。
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
class _ChatListState extends State<ChatList> {
ChatController get chatController => widget.chatController;
ScrollController get scrollerScroller => chatController.scrollController;

Widget get _chatStreamBuilder => StreamBuilder<List<MessageModel>>(
builder:
(BuildContext context, AsyncSnapshot<List<MessageModel>> snapshot) {
return snapshot.connectionState == ConnectionState.active
? ListView.builder(
shrinkWrap: true,
reverse: true,
padding: widget.padding,
controller: scrollerScroller,
itemCount: snapshot.data?.length ?? 0,
itemBuilder: (BuildContext context, int index) {
var model = snapshot.data![index];
return VisibilityAwareMessageWidget(
key: model.key,
message: model,
onWillDisplay: () {
// print('🎉 消息【${model.content}】出现啦!用户:${model.ownerName}');
},
onDidEndDisplaying: () {
// print('🚀 消息【${model.content}】消失了~用户:${model.ownerName}');
},
);
})
: const Center(child: CircularProgressIndicator());
},
stream: chatController.messageStreamController.stream,
);

@override
Widget build(BuildContext context) {
// 配合 shrinkWrap : true使用,解决数据少的时候数据底部对齐的问题
return Align(
alignment: Alignment.topCenter,
child: _chatStreamBuilder,
);
}
}

StreamController.sink.add 用于向流中添加数据事件,通知所有监听者。

调用 add 后,所有监听该流的 StreamBuilder 或 listen 方法会收到新数据。

1
2
3
4
5
6
7
8
9
10
11
@override
void addMessage(MessageModel message) {
if (messageStreamController.isClosed) {
return;
}
inflateMessage(message);
// List翻转后,是从底部向上展示,所以新来的消息需要插入到数据的第0个位置
initialMessageList.insert(0, message);
messageStreamController.sink.add(initialMessageList);
scrollToLastMessage();
}

除了使用StreamBuilder进行流的管理外,Flutter中针对单个widget组件还设置了GlobalKey用于管理是否进行重绘。

三、Flutter UI组件曝光/消失的监听方案

和iOS不同,谷歌生产的组件,无论是Android还是Flutter,都没有现成的 曝光/消失 回调,所以需要业务自己去实现。

在介绍Flutter监听UI控件之间,我们先简单介绍2个知识点,然后最终我们由这2个知识点组成一个完成的方案。

(一)Flutter的渲染流程

Flutter的UI构建和渲染分为三个阶段,这三个阶段都是在RenderObject中执行(在构建RenderObject方法前的build方法是在Flutter的Widget生命周期中非常核心的部分,它定义了Widget的结构,但并不涉及任何关于尺寸或位置的决定)

1. 布局(Layout):确定每个Widget的位置和尺寸

每个Widget都有一个对应的RenderObject,这个RenderObject负责执行实际的布局逻辑。

在布局阶段,RenderObject的performLayout方法被调用。

这个方法负责计算和确定Widget的大小,并根据子Widget的大小和位置来决定自己的布局。

这个过程是递归的:从根节点(通常是RenderView)开始,向下通过Widget树传递,每个节点都会在其子节点布局完成后确定自己的布局。

2. 绘制(Paint):将Widget绘制到画布上

绘制阶段发生在布局阶段之后,此时每个Widget的位置和大小都已确定。

在绘制阶段,RenderObject的paint方法被调用,负责将Widget的视觉表现绘制到屏幕上。

3. 合成(Composite):将多个图层合并为最终的屏幕图像

最后,在合成阶段,所有的绘制操作被合并到一起,并最终渲染到屏幕上。这通常涉及到图层的合成和可能的GPU加速。

(二) addPostFrameCallback 回调时机

addPostFrameCallback 是一帧完成的回调,需要注意的是,它的注册渲染监听是一次性的。

1
2
3
WidgetsBinding.instance.addPostFrameCallback((_) {
xxx; // 绘制完成后的回调
});

(三)visibilityDetector 监听页面生命周期

由前两步可知,paint阶段并不代表渲染完成,我们可以在 paint 阶段执行 addPostFrameCallback 进行当前帧的注册,然后帧完成后再取最终的渲染结果。

这也是开源组件 visibilityDetector 最终的实现方式,目前绝大部分Flutter的渲染方案都是基于visibilityDetector的原理去实现的。