携手创作,共同成长!这是我参与「掘金日新计划 · 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
混入了 MoveAble
和 PaintAble
,就表示 Shape
对象可以同时拥有这两个类的能力。这个功能和接口有点相似,不过混入类可以进行对方法进行实现,这点要比接口更灵活。有点 随插随用
的感觉,甚至 Shape
类中可以什么都不做,就坐拥 “王权富贵”
。
mixin PaintAble{
void paint(){
print("=====$runtimeType paint====");
}
}
class Shape with MoveAble,PaintAble{
}
值得注意一点的是:混入类支持 抽象方法
,而且同样要求派生类必须实现 抽象方法
。如下 PaintAble
的 tag1
处定义了 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. 混入类对二义性的解决方式
通过前面可以看出,混入类
可谓 上得厅堂下得厨房
,能打能抗的六边形战士。但,没有任何事物是完美无缺的。仔细想一下,既然语法上支持 多混入
,那解决二义性就是一座不可避免大山。接口
牺牲了 普通成员
和 方法实现
,可谓断尾求生,才解决二义性问题,支持 多实现
。而 混入类
又能写成员变量,又能写成员方法,那它牺牲了什么呢?答案是:
混入类不能拥有【构造方法】
这一点就从本质上限制了 混入类
无法直接创建对象,这也是它和 普通类
最大的差异。从这里可以看出,抽象类
、接口
、混入类
都具有不能直接实例化的特点。本质上是因为这三者都是更高层的抽象,可能存在未实现的功能。那为什么混入类无法构造,就能解决二义性问题呢?下面来分析一下,两个混入类中的同名成员、同名方法,在多混入场景中是如何工作的。如下测试代码所示,A
、B
两个混入类拥有同名的 成员属性
和 成员方法
:
mixin A {
String name = "A";
void log() {
print(name);
}
}
mixin B {
String name = "B";
void log() {
print(name);
}
}
此时,C
依次混入 A
、B
类,然后实例化 C
对象,执行 log
方法,可以看出,打印的是 B
。
class C with A, B {}
void main() {
C c = C();
c.log(); // B
}
如果 C
依次混入 B
、A
类,打印结果是 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
,对 initState
和 dispose
方法进行覆写并输出日志。这样在一个 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
中相对独立的概念,对混入类的理解是非常重要的,它相当于在原有的 类间六大关系
中又添加了一种。本文想说的就这么多,谢谢观看~