0%

混合开发中,flutter使用三方库依赖iOS的SDK和原生使用的SDK重叠

影响范围

主要影响和接入方式有关

一、源码接入

判断flutter使用三方库依赖iOS的SDK和原生使用的SDK是否相同:

  1. 相同: Pod会只安装一次。
  2. 不相同: 在执行pod install过程中会报错,提示依赖冲突

二、通过xcode拖入framework产物接入

未测试,应该和源码接入类似

三、通过CocoaPods拖入framework产物接入

在flutter module下通过以下指令生成flutter相关产物:
flutter build ios-framework --cocoapods --output=some/path/MyApp/Flutter/

这个时候flutter使用的三方库中,含有原生SDK的同样会生成一份framework。

默认集成中,flutter和原生共用的三方SDK会有两份包含在包里,会导致api冲突,包体积增加等问题。

解决方案
  1. **注释掉s.dependency xxx**: flutter私有化冲突的插件,在插件的iOS目录下的xxx.podspec文件中,注释掉# s.dependency xxx

  2. 新建头文件:新建xxx_plugin-Swift.h,里面只写SDK的引入

  3. 修改引入方式

    1
    2
    3
    4
    5
    6
    7
    8
    #if __has_include(<xxx_plugin/xxx_plugin-Swift.h>)
    #import <xxx_plugin/xxx_plugin-Swift.h>
    #else
    // Support project import fallback if the generated compatibility header
    // is not copied when this plugin is created as a library.
    // https://forums.swift.org/t/swift-static-libraries-dont-copy-generated-objective-c-header/19816
    #import "xxx_plugin-Swift.h"
    #endif
  4. 再次运行打包指令flutter build ios-framework --cocoapods --output=some/path/MyApp/Flutter/

自定义ProxyWidget组件

业务场景

滑动选择,根据手势判断选中的元素

需求分析

  1. 监听手势滑动
  2. 根据手势位置判断元素是否被选中
  3. 通知子元素做选中UI展示
监听手势滑动

通过GestureDetector包裹识别区域,默认使用:

  • onHorizontalDragStart
  • onHorizontalDragUpdate
  • onHorizontalDragEnd

三个方法,判断此次滑动区域起始点和结束点,以及滑动过程中选中部分。

根据手势位置判断元素是否被选中
  1. 自定义ProxyWidget

    • 标记当前widget标识,当前用下标做标识,方便状态同步。
    • 实现 createElement方法,创建一个ProxyElement 子类;
    • ProxyElement根据unmountmount生命周期,上报父容器元素自身挂载状态。
    • ProxyElement对象提供判断是否在父容器手势范围内,其中用到了renderObject,renderObject是Element的属性(Element>ComponentElement>ProxyElement)
    1
    2
    3
    4
    5
    bool containsOffset(RenderObject? ancestor, Offset offset) {
    final box = renderObject as RenderBox;
    final rect = box.localToGlobal(Offset.zero, ancestor: ancestor) & box.size;
    return rect.contains(offset);
    }
  2. 滑动过程中,获取手势当前位置信息
    在监听手势过程中,手势的回调都会返回position信息。
    _elements即子元素上报统计的集合

1
2
3
4
5
6
7
8
int _findIndexOfSelectable(Offset offset) {
final ancestor = context.findRenderObject();
final element = _elements.firstWhere(
(element) => element!.containsOffset(ancestor, offset),
orElse: () => null,
);
return (element == null) ? -1 : element.widget.index;
}

熟悉一下ProxyWidget常用子类

  1. ParentDataWidget
  2. InheritedWidget
  3. NotificationListener

ProxyWidget和ProxyElement的主要功能

ProxyWidget

  • 实现 createElement方法,创建一个ProxyElement 子类;
  • 定义widget对外属性
  • 提供子类接口
    • 例如InheritedWidgetupdateShouldNotifyInheritedWidget子类需要实现updateShouldNotify,从而决定是否通知重绘等;
    • updateShouldNotify提升到widget层,而不是在Element的notifyClients中判断,是为了让使用者不关注Element层。

ProxyElement

  • 重写notifyClients,用于对旧的数据进行对比,判断是否通知重绘等操作
  • 根据Element的生命周期,实现自定义业务需求。
  • 也可以在ProxyElement添加自定义方法,提供给业务使用。

FlutterBoost集成后WillPopScope失效问题

问题出现场景一: 在集成FlutterBoost后,打开flutter页面WillPopScope包裹页面,其对返回手势拦截的功能没有生效,手势被原生容器捕捉,由原生路由处理

问题出现场景二: 在集成FlutterBoost后,打开flutter页面WillPopScope包裹页面,部分页面生效,但onWillPop返回true和flase效果一致。

定位问题

flutter源码flutter_boost/lib/src/boost_lifecycle_binding.dartBoostLifecycleBinding类的containerDidShow方法会根据当前容器flutter页面数量,选择是否禁用原生页面手势:

1
2
3
4
5
6
7
8
9
10
11
12
void containerDidShow(BoostContainer container) {
///When this container show,we check the nums of page in this container,
///And change the pop gesture in this container
if (container.pages.length >= 2) {
BoostChannel.instance
.disablePopGesture(containerId: container.pageInfo.uniqueId!);
} else {
BoostChannel.instance
.enablePopGesture(containerId: container.pageInfo.uniqueId!);
}
...忽略部分
}

上面的代码中enablePopGesture是像原生容器发送消息开启原生容器左滑手势;disablePopGesture 是像原生容器发送消息禁止原生容器左滑手势;

这个event我们在业务代码中也可以调用,会覆盖这部分代码,使用时候需慎重,因为同一个原生容器中,也可能包含多个flutter页面。强制开启手势,左滑返回会将多个flutter页面一同返回。enablePopGesture 事件,也是通过uniqueId作为key,容器和业务代码建成的通讯channel,该channel目前只实现了对enablePopGesture 的处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/// enable iOS native pop gesture for container matching [containerId]
void enablePopGesture({required String containerId}) {
assert(containerId.isNotEmpty);
BoostChannel.instance.sendEventToNative(containerId, {
'event': 'enablePopGesture',
"args": {'enable': true}
});
}

/// disable iOS native pop gesture for container matching [containerId]
void disablePopGesture({required String containerId}) {
assert(containerId.isNotEmpty);
BoostChannel.instance.sendEventToNative(containerId, {
'event': 'enablePopGesture',
"args": {'enable': false}
});
}

问题分析

  1. 在容器的原生手势开启的时候,WillPopScope是拿不到手势相应的,侧滑会由原生路由处理,这个时候添不添加WillPopScope都没有效果
  2. 在容器的原生手势禁止的时候,页面根据是否包含WillPopScope来判断是否处理flutter侧滑手势,如果有侧滑手势,侧滑路由修的是flutter路由部分,原生容器不受影响。这里注意了,原生容器侧滑手势被禁止,即当当前容器中flutter为路由栈退出后仅剩一个,侧滑手势无人响应,需要手动将原生侧滑手势打开。

containerDidShow 判断page数量是根据容器计算的,也就是我们在跳转flutter页面的时候,带上withContainer = true后,每个flutter页面均会独享一个原生容器。

BoostNavigator.instance.push

1
2
3
4
5
6
Future<T> push<T extends Object?>(
String name, {
Map<String, dynamic>? arguments,
bool withContainer = false,
bool opaque = true,
})

遗留问题

  1. 在容器的原生手势禁止的时候,页面不会根据WillPopScope的onWillPop返回值来判断是否响应侧滑手势,而是均不可侧滑。

通过源码接入三方库正常使用,framework产物接入部分功能失效

踩坑场景

flutter在使用permission_handler库时,flutter通过源码接入宿主工程时,iOS申请权限系统弹窗正常弹出。通过打包framework方式接入宿主工程时,iOS申请系统弹窗不弹,直接返回被永久拒绝。

问题定位路线

  1. 下载permission_handler库源码,提交到私有仓库
  2. 增加日志,定位问题原因为permission_handler使用了宿主工程GCC_PREPROCESSOR_DEFINITIONS配置的一个宏定义。
  3. GCC_PREPROCESSOR_DEFINITIONS是用于设置预处理器宏的。这些宏在编译时会被替换。然而,这些宏只能在编译时期的源代码中使用,不能在运行时期的代码中使用。因此,如果你在framework中尝试读取这些宏,是无法读取到的。
  4. permission_handler的iOS部分源码,通过宏是判断业务代码是否开启了某一权限白名单,获取到宏且宏定义为1,即调用申请权限流程。否则直接返回权限被永久拒绝。

解决方案

通过设置permission_handler的iOS源码中默认宏配置,开启对应白名单。打包提交私有库,页面代码修改 permission_handler路径指向为修改后的私有库地址。

Flutter 各种列表曝光方案

目前的曝光有两大场景:播放器可视播放和元素曝光
其中元素曝光还需要细分,有主轴方向滑动曝光,还有混合纵轴方向滑动曝光

场景一:滑动中可视元素内选取元素播放或者停止播放

场景特点

需要在滑动中获取当前可视元素和移除可视范围的元素

  1. 先判断移除移除可视的元素是否存在播放,如果有,停止播放。
  2. 在上一步执行完毕之后,判断新的可视元素中是否含符合播放标准
  3. 开启下一轮播放
  4. 播放结束前没有被划走,继续监测其他符合播放的元素播放

曝光特点

  1. 实时监听滑动中视口内元素变化
  2. 在监听回调中需要同时拿到当前所有可视元素,进行规则匹配

技术方案

flutter_scrollview_observer

API
1
2
3
4
5
/// The callback of getting observed result map.
final Function(Map<BuildContext, M>)? onObserveAll;

/// The callback of getting observed result for first sliver.
final Function(M)? onObserve;

sliverListContexts如果不为null,列表会通过element.visitChildren方法获取的sliverlist列表。

onObserveAll :是返回所有sliverlist回调

注意:onObserveAll返回所有sliverlist,并不能一一绑定。需要通过函数返回的Map实例的key进行查找。

onObserve :是返回第一个sliverlist回调

场景二:可视曝光

元素进入屏幕视口内回调,一般用于曝光打点

场景特点

  1. 用于列表元素曝光打点

曝光特点

  1. 每次曝光处理,元素独自处理,和列表其他元素曝光没有耦合。

技术方案

flutter_exposure

  • ScrollDetailProvider嵌套滑动列表,监听滑动通知并根据需求转化通知到子组件
  • Exposure嵌套需要统计的单个元素,Exposure接受ScrollDetailProvider转化的通知,根据自身的偏移量判断是否在视口内。
  • ExposureController实例可以绑定多个Exposure,通过调用reCheckExposeState方法手动获取一遍当前视口曝光情况

场景三:可视曝光——特殊组合

我们在监测主轴曝光的同时,对特殊元素需要监测纵轴方向曝光。

场景特点

  • 曝光列表中特殊元素曝光
  • 适用于任何renderObject

曝光特点

  • 每次曝光处理,元素独自处理,和列表其他元素曝光没有耦合。

技术方案

visibility_detector
个人认为效率不高,而且回调次数较多。

  • 需要监听的组件通过该三方组件直接嵌套

  • 监听onVisibilityChanged回调,通过可视比例判断是否满足曝光

  • 在元素paint的方法中调用元素(PaintingContext)的addCompositionCallback方法,

  • 在上述方法中,调用计算任务去判断可视比例

  • 计算任务,通过获取父级列表(到顶端),通过recttransform计算当前元素的bounds,组合成VisibilityInfo

    1
    2
    3
    4
    5
    final List<ContainerLayer> ancestors = <ContainerLayer>[ContainerLayer()];
    while (ancestor != null && ancestor.parent != null) {
    ancestors.add(ancestor);
    ancestor = ancestor.parent;
    }
  • VisibilityInfo中有visibleBounds——实际可视的区域,visibleFraction——显示比例

Kalman Filter

Kalman滤波被称为线性二次型估计。其也可以认为是一个最优化自回归数据处理算法(optimal recursive data processing algorithm)。前提是这个数据变化是线性变化的。

对于其原理我们可以参考Kalman Filter。文章写的很专业,我就不废话。

应用场景

当我们要观察一个仪器测量值,比如力度、重量等测量,在采集过程中,我们会不断拿到新数据,这些数据是采集器从目标中收集出来,是属于线性变化的。但采集器的精准度和目标物体的都含有不确定行。我们需要对这些数据进行过滤,生成平滑的数据。目标如下图:

蓝线是原始数值,绿线是我们想要的结果。

Kalman Filter dart(Flutter)代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class KalmanFilter {
final double q; // 过程噪声协方差
final double r; // 测量噪声协方差
double x; // 当前状态
double p; // 当前状态的协方差
late double k; // 卡尔曼增益

KalmanFilter({this.q = 0.1, this.r = 0.1, this.x = 0, this.p = 1});

double update(double measurement) {
// 预测步骤
final x_pred = x;
final p_pred = p + q;

// 更新步骤
k = p_pred / (p_pred + r);
x = x_pred + k * (measurement - x_pred);
p = (1 - k) * p_pred;

return x; // 返回滤波后的值
}
}

过程噪声协方差

它用来描述状态量在一个时刻到下一个时刻之间的变化幅度。一般来说,过程噪声方差是由系统的动态特性和采样间隔决定的。

如果系统的动态特性比较快,即状态量在一个时刻到下一个时刻之间的变化幅度比较大,那么过程噪声方差就应该设置得比较大,以便更好地反映状态量的变化。反之,如果系统的动态特性比较慢,即状态量在一个时刻到下一个时刻之间的变化幅度比较小,那么过程噪声方差就应该设置得比较小,以便更好地适应状态量的变化。

另外,采样间隔也会影响过程噪声方差的定义。如果采样间隔比较大,即状态量的变化相对较慢,那么过程噪声方差应该设置得比较小,以充分利用状态量的变化信息。反之,如果采样间隔比较小,即状态量的变化相对较快,那么过程噪声方差应该设置得比较大,以避免过度拟合状态量的变化。

因此,过程噪声方差的定义需要考虑系统的动态特性和采样间隔,需要进行实验和分析来确定最佳值。一般来说,过程噪声方差可以通过试验和经验得到,也可以使用自适应卡尔曼滤波器来自动调整。

观测噪声协方差

观测噪声协方差用来描述测量噪声对状态估计的影响。通常情况下,观测噪声协方差是由测量设备的精度和信噪比等因素决定的。

如果测量设备的精度越高,即测量误差越小,那么观测噪声协方差就应该设置得比较小,以更准确地反映状态量的真实值。反之,如果测量设备的精度较低,即测量误差较大,那么观测噪声协方差就应该设置得比较大,以充分考虑测量误差的影响。

另外,信噪比也会影响观测噪声协方差的定义。如果信噪比越高,即信号强度比噪声强度高,那么观测噪声协方差就应该设置得比较小,以更好地反映状态量的真实值。反之,如果信噪比较低,即信号强度比噪声强度低,那么观测噪声协方差就应该设置得比较大,以充分考虑噪声的影响。

因此,观测噪声协方差的定义需要考虑测量设备的精度和信噪比等因素,并且需要根据具体的应用场景进行实验和分析来确定最佳值。一般来说,可以通过实际测量数据的分析和处理来估计观测噪声协方差的值。

基础使用

1
2
3
4
5
6
7
8
9
// 初始化
final kalmanFilter =
KalmanFilter(q: 0.01, r: 0.1, x: originDatas.first.toDouble(), p: 1);
// 接收过滤结果
List<double> fitterRes = [];
for (final measurement in originDatas) {
final filteredMeasurement = kalmanFilter.update(measurement.toDouble());
fitterRes.add(filteredMeasurement);
}

上面这个代码片段只是简单过滤一组数据。在真实使用中当前状态协方差p和卡尔曼增益k会在每次检测流程中根据历史调整,而不是每次update的时候新建。所以是最好是通过一个单例的管理类去管理每次检测流程。

事件分发机制

  1. 命中测试
  2. 事件分发
  3. 事件清理

命中测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/// A RenderProxyBox subclass that allows you to customize the
/// hit-testing behavior.
abstract class RenderProxyBoxWithHitTestBehavior extends RenderProxyBox{

@override
bool hitTest(BoxHitTestResult result, { required Offset position }) {
bool hitTarget = false;
if (size.contains(position)) {
hitTarget = hitTestChildren(result, position: position) || hitTestSelf(position);
if (hitTarget || behavior == HitTestBehavior.translucent) {
result.add(BoxHitTestEntry(this, position));
}
}
return hitTarget;
}

@override
bool hitTestSelf(Offset position) => behavior == HitTestBehavior.opaque;

...
}
  • 手指按下触发PointerDownEvent事件,按照深度优先开始进行命中测试
  • 如果子组件命中返回为ture,则先被加入命中结果数组,其父组件同样也会返回true,并加入命中结果数组。这一点在接口RenderProxyBoxWithHitTestBehaviorhitTest方法中体现——hitTarget = hitTestChildren(result, position: position) || hitTestSelf(position))。
  • 这里要注意如果是多个子组件,是如何去判断命中的。这里我们看一下RenderBoxContainerDefaultsMixins的实现:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
bool defaultHitTestChildren(BoxHitTestResult result, { required Offset position }) {
ChildType? child = lastChild;
while (child != null) {
// The x, y parameters have the top left of the node's box as the origin.
final ParentDataType childParentData = child.parentData! as ParentDataType;
final bool isHit = result.addWithPaintOffset(
offset: childParentData.offset,
position: position,
hitTest: (BoxHitTestResult result, Offset transformed) {
assert(transformed == position - childParentData.offset);
return child!.hitTest(result, position: transformed);
},
);
if (isHit) {
return true;
}
child = childParentData.previousSibling;
}
return false;
}

多个子组件,遍历子组件(从后往前遍历),有命中的就终止遍历,返回结果。

为什么从后往前(previousSibling),而不是从前往后(nextSibling)?

自问自答:我个人理解是命中区域会有重叠,如Stack。当Stack有重叠的时候,最上层显示children中靠后的,当然事件也优先让他们去命中测试。

命中测试当前作用手机了命中结果数组HitTestResult

事件分发

GestureBinding类为单例类,集成自BindingBase,并从BindingBase中获取platformDispatcher,并对platformDispatcher的触摸事件处理进行方法进行赋值。
事件分发调用是由platformDispatcher单例去管理的。

1
ui.PlatformDispatcher get platformDispatcher => ui.PlatformDispatcher.instance

GestureBinding管理Queue,调用_handlePointerEventImmediately并在合适时机遍历分发event到目标上,包括hitTest命中测试方法,

dispatchEvent方法先判断事件类型,如果HitTestResult有结果,遍历调用HitTestTargethandleEvent方法,组件只需要重新handleEvent方法去处理事件就好。对于我们日常用的ListenerGestureDetector(Listener的封装)均在ListenercreateRenderObject方法返回的RenderPointerListener中实现:

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
@override
void handleEvent(PointerEvent event, HitTestEntry entry) {
assert(debugHandleEvent(event, entry));
if (event is PointerDownEvent) {
return onPointerDown?.call(event);
}
if (event is PointerMoveEvent) {
return onPointerMove?.call(event);
}
if (event is PointerUpEvent) {
return onPointerUp?.call(event);
}
if (event is PointerHoverEvent) {
return onPointerHover?.call(event);
}
if (event is PointerCancelEvent) {
return onPointerCancel?.call(event);
}
if (event is PointerPanZoomStartEvent) {
return onPointerPanZoomStart?.call(event);
}
if (event is PointerPanZoomUpdateEvent) {
return onPointerPanZoomUpdate?.call(event);
}
if (event is PointerPanZoomEndEvent) {
return onPointerPanZoomEnd?.call(event);
}
if (event is PointerSignalEvent) {
return onPointerSignal?.call(event);
}
}

handleEvent根据具体的事件类型,调用传入的回调方法。

至此事件完整的分发出去了。

事件清理

事件清理也是在_handlePointerEventImmediately方法中进行,当收到upcancel等事件后,进行相应的分发,并从hitTestResult中移除

记录个人学习过程,不对的地方欢迎大佬指正

自定义flex组件,获取子组件布局信息

我们都在用ColumnRow,他们都是Flex子类,将children中组件纵向或者横向进行摆放。

现在有一个需求是摆放完成后,需要知道children中各个widget所占的宽或者高,从而知道他们具体的位置锚点,然后可以根据这些锚点进行对children添加精准动画

目标效果

我们先来了解一下需要用到的基础知识点Flex

Flex

根据主轴方向显示一个一维widget数组。

其中ColumnRow就是Flex子类,他们直接把主轴方向写死简单的进行一次封装。

Flex集成自MultiChildRenderObjectWidget,实现两个主要方法createRenderObjectcreateRenderObject,这两个方法分别被Element在挂载(mount)和更新(update)的时候调用。

所以Flex只是MultiChildRenderObjectWidget的一个子类,具体布局还是父类去实现的,Flex通过重写createRenderObject指定返回元素RenderFlex,具体元素的加载是在RenderFlex中执行的,我们想第一时间拿到布局信息,也是在RenderFlexperformLayout中获取

RenderObjectElement 源码

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
abstract class RenderObjectElement extends Element {
/// Creates an element that uses the given widget as its configuration.
RenderObjectElement(RenderObjectWidget super.widget);
···

RenderObject? _renderObject;

@override
void mount(Element? parent, Object? newSlot) {
super.mount(parent, newSlot);
···
_renderObject = (widget as RenderObjectWidget).createRenderObject(this);
···
super.performRebuild(); // clears the "dirty" flag
}

@override
void update(covariant RenderObjectWidget newWidget) {
super.update(newWidget);
···
_performRebuild(); // calls widget.updateRenderObject()
}

@pragma('vm:prefer-inline')
void _performRebuild() {
···
(widget as RenderObjectWidget).updateRenderObject(this, renderObject);

···
super.performRebuild(); // clears the "dirty" flag
}
}

  • mount: RenderObject 对象的创建,以及与渲染树的插入工作,插入到渲染树后的 Element 就可以显示到屏幕中了。
  • update: 如果 Widget 的配置数据发生了改变,那么持有该 Widget 的 Element 节点也会被标记为 dirty。在下一个周期的绘制时,Flutter 就会触发 Element 树的更新,并使用最新的 Widget 数据更新自身以及关联的 RenderObject 对象,接下来便会进入 Layout 和 Paint 的流程。而真正的绘制和布局过程,则完全交由 RenderObject 完成。

自定义Flex组件

这里我们先自定义flex组件,想要拿到并更新我们的位置信息,我们就得重新两个重要方法:createRenderObjectcreateRenderObject

定义一个LayoutCallback回调类型,在拿到我们想要的布局后通过这个回调去处理,并在需要更新的时候去调用。

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

typedef LayoutCallback = void Function(
List<double> xOffsets, TextDirection textDirection, double width);

class TabLabelBar extends Flex {
TabLabelBar({
Key? key,
List<Widget> children = const <Widget>[],
required this.onPerformLayout,
}) : super(
key: key,
children: children,
direction: Axis.horizontal, // 根据自己需求
);

final LayoutCallback onPerformLayout;

@override
RenderFlex createRenderObject(BuildContext context) {
return TabLabelBarRenderer(
direction: direction,
mainAxisAlignment: mainAxisAlignment,
mainAxisSize: mainAxisSize,
crossAxisAlignment: crossAxisAlignment,
textDirection: getEffectiveTextDirection(context)!,
verticalDirection: verticalDirection,
onPerformLayout: onPerformLayout,
);
}

@override
void updateRenderObject(
BuildContext context, _TabLabelBarRenderer renderObject) {
super.updateRenderObject(context, renderObject);
renderObject.onPerformLayout = onPerformLayout;
}
}
  • createRenderObject Element在挂载(mount)到树上通过调用该方法,去创建widget对应的renderObject。
  • updateRenderObject 在Widget配置数据更新后,修改对应的 Render Object。该方法在首次 build 以及需要更新 Widget 时都会调用

去实现createRenderObject方法

Flex的createRenderObject方法返回的RenderFlex。接下来要做的就是去实现自己的RenderFlex,肯定是去继承RenderFlex,重写相关方法,在重写中拿到我们想要的数据并回调。

  1. 将回调方法传入我们定义RenderFlex子类
  2. 在子类layout完毕的时候拿到布局信息,整合并回调
    1. RenderFlex继承RenderBox,RenderBox混入ContainerRenderObjectMixin 、 RenderBoxContainerDefaultsMixin 和 ContainerBoxParentData,通过 ContainerBoxParentData ,我们可以将 RenderBox 需要的 BoxParentData 和上面的 ContainerParentDataMixin 组合起来,事实上我们得到的 children 双链表就是以 ParentData 的形式呈现出来的。
    2. 要拿到所有同级widget布局,就要去先去拿到父级的renderObject
    3. 要拿到父级renderObject,我们可以通过其中一个子级的parentData
    4. ContainerRenderObjectMixin中的firstChild、lastChild、childCount。RenderFlex中可以直接能达到第一个元素firstChild。
    5. firstChild拿到parentData,然后就准确拿到了同级的RenderBox
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

class TabLabelBarRenderer extends RenderFlex {
TabLabelBarRenderer({
List<RenderBox>? children,
required Axis direction,
required MainAxisSize mainAxisSize,
required MainAxisAlignment mainAxisAlignment,
required CrossAxisAlignment crossAxisAlignment,
required TextDirection textDirection,
required VerticalDirection verticalDirection,
required this.onPerformLayout,
}) : super(
children: children,
direction: direction,
mainAxisSize: mainAxisSize,
mainAxisAlignment: mainAxisAlignment,
crossAxisAlignment: crossAxisAlignment,
textDirection: textDirection,
verticalDirection: verticalDirection,
);

LayoutCallback onPerformLayout;

@override
void performLayout() {
super.performLayout();
// xOffsets will contain childCount+1 values, giving the offsets of the
// leading edge of the first tab as the first value, of the leading edge of
// the each subsequent tab as each subsequent value, and of the trailing
// edge of the last tab as the last value.
RenderBox? child = firstChild;
final List<double> xOffsets = <double>[];
while (child != null) {
final FlexParentData childParentData =
child.parentData! as FlexParentData;
xOffsets.add(childParentData.offset.dx);
assert(child.parentData == childParentData);
//nextSibling: The next sibling in the parent's child list.
child = childParentData.nextSibling;
}
assert(textDirection != null);
switch (textDirection!) {
case TextDirection.rtl:
xOffsets.insert(0, size.width);
break;
case TextDirection.ltr:
xOffsets.add(size.width);
break;
}
onPerformLayout(xOffsets, textDirection!, size.width);
}
}

使用

到此,我们已经封装完毕。

1
2
3
4
TabLabelBar(
onPerformLayout: _saveOffsets,
children: [...widgets],
),

Notification

NotificationListener也是冒泡传递,和触摸Listener不同的是:NotificationListener可以通过onNotification阻止继续冒泡,而Listener不行。

通知使用流程

通知类,每个节点都可以发送通知Notification,每个节点也都可以接收通知。通知通过冒泡逐级往父类查找对于泛型的NotificationListener监听。

下面我们先制定一个通知类继承Notification, 走一遍通知流程:

自定义通知类

1
2
3
4
5
6
7
import 'package:flutter/material.dart';

class RZNotification extends Notification {
final int count;
RZNotification({required this.count}) : super();
}

发送通知

通过通知实例,调用dispatch方法,dispatch方法默认在抽象类Notification实现

1
2
RZNotification notification = RZNotification(count: 5);
notification.dispatch(context);

监听通知

1
2
3
4
5
6
7
NotificationListener<RZNotification>(
onNotification: (RZNotification rzNotification) {
print('===rzNotification==00=${rzNotification.count}');
return true;
},
child:

NotificationListener

监听类只有两个成员child和onNotification,一个私有方法(_dispatch),一个build方法,build方法直接返回传入child。

_dispatch

通知类通过element树查找所有通知监听类NotificationListener ,调用监听类的_dispatch,并传入通知类实例。_dispatch 方法校验是否属于自己监听的泛型类,如果是并且onNotification方法返回为true。

接收Notification对应泛型子类的通知,对于泛型子类通过dispatch(BuildContent? target)方法——通过element树层层往上级查找,找到对应泛型的Listener,即调用onNotification回调。

再看Notification

dispatch(BuildContent? target)

Notification类为抽象类,子类直接调用dispatch方法,通过传入BuildContentvisitAncestorElements查找,并传入查找逻辑(visitor)

visitAncestorElements是BuildContent实例方法:

1
2
3
4
5
6
7
@override
void visitAncestorElements(bool Function(Element element) visitor) {
assert(_debugCheckStateIsActiveForAncestorLookup());
Element? ancestor = _parent;
while (ancestor != null && visitor(ancestor))
ancestor = ancestor._parent;
}

个人理解是传入一个访问逻辑,visitAncestorElements逐级往上查找父类——do循环,如果父类不为空,将父类传入访问逻辑中,如果访问逻辑返回true就继续查找改父类的父类,如果返回false,就停止查找。

visitAncestor

访问逻辑visitAncestor,并不知道是那个子类调用的dispatch方法,所以先查找widget is NotificationListener<Notification>,是的话就调用NotificationListener的_dispatch方法,并传入调用者,NotificationListener都知道自己监听的泛型类是什么,如果是调用者是自己监听的类,即调用onNotification回调。

1
2
3
4
5
6
7
8
9
10
11
12
@protected
@mustCallSuper
bool visitAncestor(Element element) {
if (element is StatelessElement) {
final StatelessWidget widget = element.widget;
if (widget is NotificationListener<Notification>) {
if (widget._dispatch(this, element)) // that function checks the type dynamically
return false;
}
}
return true;
}

总结

通过NotificationListener和Notification配合,我们清晰知道了通知发送和接收的逻辑。通知这种方式的通信也有它的局限性,监听者只能监听其子widget发送的通知。所以我们也需要根据应用场景实际情况选择性去使用。

并且熟悉了Context和Element关系:

  • Context遍历Element树的方法
  • 可以通过Element.widget得到element节点对应的widget