Flutter 语法进阶 | 深入理解混入类 mixin

9,112 阅读6分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第 3 天,点击查看活动详情


混入类引言

混入类是 Dart 中独有的概念,它是 继承实现 之外的另一种 is-a 关系的维护方式。它和接口非常像,一个类支持混入多个类,但在本质上和接口还是有很大区别的。在感觉上来说,从耦合性来看,混入类像是 抽象类接口 的中间地带。下面就来认识一下混入类的 使用与特性


1. 混入类的定义与使用

混入类通过 mixin 关键字进行声明,如下的 MoveAble 类,其中可以持有 成员变量 ,也可以声明和实现成员方法。对混入类通过 with 关键字进行使用,如下的 Shape 混入了 MoveAble 类。在下面 main 方法测试中可以看出,混入一个类,就可以访问其中的成员属性与方法,这点和 继承 非常像。

void main(){
  Shape shape = Shape();
  shape.speed = 20; 
  shape.move();//=====Shape move====
  print(shape is MoveAble);// true
}
​
mixin MoveAble{
  double speed = 10;
  void move(){
    print("=====$runtimeType move====");
  }
}
​
class Shape with MoveAble{
​
}

一个类可以混入若干个类,通过 , 号隔开。如下 Shape 混入了 MoveAblePaintAble ,就表示 Shape 对象可以同时拥有这两个类的能力。这个功能和接口有点相似,不过混入类可以进行对方法进行实现,这点要比接口更灵活。有点 随插随用 的感觉,甚至 Shape 类中可以什么都不做,就坐拥 “王权富贵”

mixin PaintAble{
  void paint(){
    print("=====$runtimeType paint====");
  }
}
​
class Shape with MoveAble,PaintAble{
}

值得注意一点的是:混入类支持 抽象方法 ,而且同样要求派生类必须实现 抽象方法 。如下 PaintAbletag1 处定义了 init 抽象方法,在 Shape 中必须实现,这一点又和 抽象类 有些相像。所以我说混入类像是 抽象类接口 的中间地带,它不像继承那样单一,也不像接口那么死板。

mixin PaintAble{
  late Paint painter;
  void paint(){
    print("=====$runtimeType paint====");
  }
  void init();// tag1
}
​
class Shape with MoveAble,PaintAble{
  @override
  void init() {
    painter = Paint();
  }
}

2. 混入类对二义性的解决方式

通过前面可以看出,混入类 可谓 上得厅堂下得厨房 ,能打能抗的六边形战士。但,没有任何事物是完美无缺的。仔细想一下,既然语法上支持 多混入 ,那解决二义性就是一座不可避免大山。接口 牺牲了 普通成员方法实现 ,可谓断尾求生,才解决二义性问题,支持 多实现 。而 混入类 又能写成员变量,又能写成员方法,那它牺牲了什么呢?答案是:

混入类不能拥有【构造方法】

这一点就从本质上限制了 混入类 无法直接创建对象,这也是它和 普通类 最大的差异。从这里可以看出,抽象类接口混入类 都具有不能直接实例化的特点。本质上是因为这三者都是更高层的抽象,可能存在未实现的功能。那为什么混入类无法构造,就能解决二义性问题呢?下面来分析一下,两个混入类中的同名成员、同名方法,在多混入场景中是如何工作的。如下测试代码所示,AB 两个混入类拥有同名的 成员属性成员方法 :

mixin A {
  String name = "A";
​
  void log() {
    print(name);
  }
}
​
mixin B {
  String name = "B";
​
  void log() {
    print(name);
  }
}

此时,C 依次混入 AB 类,然后实例化 C 对象,执行 log 方法,可以看出,打印的是 B

class C with A, B {}
​
void main() {
  C c = C();
  c.log(); // B
}

如果 C 依次混入 BA 类,打印结果是 A 。也就是说对于多混入来说,混入类的顺序是至关重要的,当存在二义性问题时,会 “后来居上” ,访问最后混入类的方法或变量。这点往往会被很多人忽略,或压根不知道。

class C with B, A {}
​
void main() {
  C c = C();
  c.log(); // A
}

另外,补充一个小细节,如果 C 类覆写了 log 方法,那么执行时毋庸置疑是走 C#log 。由于混入类支持方法实现,所以派生类中可以通过 super 关键字触发 “基类” 的方法。同样对于二义性的处理也是 “后来居上” ,下面的 super.log() 执行的是 B 类方法。这种特性常用于对有生命周期的类进行拓展的场景,比如 AutomaticKeepAliveClientMixin

class C with A, B {
  
  @override
  void log() {
    super.log();// B
    print("C");
  }
}

3.混入类间的继承细节

另外,两个混入类间可以通过 on 关键字产生类似于 继承 的关系:如下 MoveAble on Position 之后,MoveAble 类中可以访问 Position 中定义的 vec2 成员变量。


但有一点要特别注意,由于 MoveAble on Position ,当 Shape with MoveAble 时,必须在 MoveAble 之前混入 Position 。这点可能很多人也都不知道。

class Shape with Position,MoveAble,PaintAble{
​
}

另外,混入类并非仅由mixin 声明,一切满足 没有构造方法 的类都可以作为混入类。比如下面 A普通类B接口(抽象)类 ,都可以在 with 后作为 混入类被对待 。也就是说,一个类的可以用多重身份,并非是互斥的,它具体是什么身份,要看使用的场景。而使用场景最醒目的标志是 关键字

关键字类关系耦合性
extend继承
implements实现
with混入
class A {
  String name = "A";
​
  void log() {
    print(name);
  }
}
​
abstract class B{
  void log();
}
​
class C with A, B {
​
  @override
  void log() {
    super.log();// B
    print("C");
  }
}

4.根据源码理解混入类

混入类在 Flutter 框架层的使用是非常多的,在 《Flutter 渲染机制 - 聚沙成塔》的 十二章 结合源码介绍了混入类的价值。下面来举个混入类的使用场景,会有些难,新手适当理解。比如 AutomaticKeepAliveClientMixin 继承 State :

mixin AutomaticKeepAliveClientMixin<T extends StatefulWidget> on State<T> {}

所以它可以在 State 的生命周期相关回调方法中做额外的处理,来实现某些特定的功能能。

这样,当在 State 派生类中混入 AutomaticKeepAliveClientMixin ,根据混入类二义性的特点,对于已经覆写的方法,可以通过 super.XXX 访问混入类的功能。对于未覆写的方法,会默认走混入类方法,这样就可以形成一种 "可插拔" 的功能件。

举个更易懂的例子,如下定义一个 LogStateMixin ,对 initStatedispose 方法进行覆写并输出日志。这样在一个 State 派生类中混入 LogStateMixin 就可以不动声色地实现生命周期打印功能,不想要就不混入。对于一些逻辑相对独立,或可以进行复用的拓展功能,使用 mixin 是非常方便的。

mixin LogStateMixin<T extends StatefulWidget> on State<T> {
​
  @override
  void initState() {
    super.initState();
    print("====initState====");
  }
​
  // 略其他回调...
  
  @override
  void dispose() {
    super.dispose();
    print("====dispose====");
  }
}

源码中有大量的混入类应用场景,大家可以自己去发现一下。本文从更深层次,分析了混入类的来龙去脉,它和 继承接口 的差异。作为 Dart 中相对独立的概念,对混入类的理解是非常重要的,它相当于在原有的 类间六大关系 中又添加了一种。本文想说的就这么多,谢谢观看~