Flutter 和 Dart 概述

本文最后更新于 2021年4月4日 晚上

这篇文章主要讲 Dart 和 Flutter 的一些基础性内容.

Dart 语言

官网: https://www.dartlang.org/

关于开发的 IDE, 目前来看 VS Code 有点太简易了, 换成 Ideal 的社区版先, 只是无法接受 VS Code 在折叠行的时候有时还会出现水平滚动, 对于使用 MAC 触摸板并不友好, 但用鼠标的情况下倒是没什么问题.

下载安装好了, 只需要添加 Flutter 插件即可.

讲解 Dart 的文章除了官方文档, 还有这些:

https://hackernoon.com/why-flutter-uses-dart-dd635a054ebf

在 Dart 中的多线程机制叫做 Isolate, 其实现为不同的 Isolate 不会共享内存, 这样也就不存在共享资源时候加锁了. 在多个 Isolate 间使用消息进行通信.

Dart 和 JS 类似, 是单线程的, 提供 Future API 或 async/await 作异步支持, 详见这个链接.

Dart 的内存回收机制专门针对大量小对象回收进行了优化, 这样在更新 Widget 显示的时候尤其高效.

Dart 2 中的 new 关键字是可选的.

定义函数

1
2
3
4
// 定义函数
void printNumber(int num) {
print('需要显示的数字是: $num');
}

Dart 中需要有一个 Main 函数作为入口, 且调用 runApp 来启动整个程序.

一些点

  • 有类型推断
  • 任何东西都是对象
  • 任意类型的定义使用 dynamic, 类似于 Any 的效果
  • var 表示变量
  • final 表示常量, 类似 let.
  • 有泛型
  • 有顶层的函数, 也有类方法和对象方法
  • 有全局变量或常量, 有类属性或对象属性.
  • 没有访问控制关键字, 取代的是命名上的约定, 比如下划线开头的是 private 的.

变量常量定义

1
2
3
var xxx = ddd;
dynamic aObj = 'Hello';
String aString = 'World';

变量定义时, 如果没有赋初值, 则默认的初值都是 null. 万物皆对象, 故 int 的初值也会是 null…

1
2
int lineCount;
assert(lineCount == null);

assert 代码会在生产环境下被忽略, 但在开发环境下的作用十分强大.

final 和 const

final 定义的量只能被赋值一次.

const 定义的常量是编译时常量, 即在编译阶段就确定了它的值和类型.

const 隐含了该量是 final.

final 的顶层常量或类常量属性在第一次使用的时候就会被初始化, 这个机制可以用来生成单例对象.

final 和 swift 中的 let 看来作用是完全相同的.

之前可能会遇到用 const 修饰的字面量, 比如 const [].

内置类型

  • numbers
  • strings
  • booleans
  • lists (also known as arrays)
  • maps
  • runes (for expressing Unicode characters in a string)
  • symbols

Both int and double are subtypes of num., 即 int 和 double 都是 num 的子类型.

下面是数值和字符串间的转换:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// String -> int
var one = int.parse('1');
assert(one == 1);

// String -> double
var onePointOne = double.parse('1.1');
assert(onePointOne == 1.1);

// int -> String
String oneAsString = 1.toString();
assert(oneAsString == '1');

// double -> String
String piAsString = 3.14159.toStringAsFixed(2);
assert(piAsString == '3.14');

另外 double 是 64 位的, int 根据平台不同有不同, 最长不超过 64位. 在 int 上可以进行多种位操作:

1
2
3
assert((3 << 1) == 6); // 0011 << 1 == 0110
assert((3 >> 1) == 1); // 0011 >> 1 == 0001
assert((3 | 4) == 7); // 0011 | 0100 == 0111

字符串可以用单引号也可以双引号, 看情况而定, 总地来说仅仅是为了避免转义:

1
2
3
const a = '"str" 是字符串';
const b = "'another str' 也是字符串";
const c = '这个字符串需要转义 \'abc\'';

字符串可以使用 == 来判等, 估计也是重载的操作符?

多行字符串借鉴了 swift 的, 可以使用 ''', 或者 """ 来.

可以检查是否是数值:

1
2
3
// Check for NaN.
var iMeantToDoThis = 0 / 0;
assert(iMeantToDoThis.isNaN);

这个和 JS 的类似.

list 的话就是 Array:

1
2
3
4
5
6
var list = [1, 2, 3];
assert(list.length == 3);
assert(list[1] == 2);

list[1] = 1;
assert(list[1] == 1);

还可以创建一个常量的 list 赋值给变量:

1
2
var constantList = const [1, 2, 3];
// constantList[1] = 1; // Uncommenting this causes an error.

Map 就是字典:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
var gifts = {
// Key: Value
'first': 'partridge',
'second': 'turtledoves',
'fifth': 'golden rings'
};

var nobleGases = {
2: 'helium',
10: 'neon',
18: 'argon',
};

var gifts = Map();
gifts['first'] = 'partridge';
gifts['second'] = 'turtledoves';
gifts['fifth'] = 'golden rings';

var nobleGases = Map();
nobleGases[2] = 'helium';
nobleGases[10] = 'neon';
nobleGases[18] = 'argon';

Symbol: 在常量或变量前加 # 号就可以获取它对应的 symbol.

函数

  • 使用 {} 把参数那一坨括起来的话, 就可以定义有名字的参数, 默认调用时参数是没有名字的:
1
2
3
void sayHello({String word}) {
print(word);
}

看懂这个的话就知道为什么这么多大括号在 API 里面了… 擦

默认情况下所有参数都是可忽略的!! 果然是!!!

  • 使用 @required 标记参数是必须的!!!
1
2
3
void sayHello({@required String word}) {
print(word);
}
  • 如果用中括号, 则显式表示这些参数是可选的.

  • 可以设置默认参数! 和 swift 一样.

  • main 函数实际有一个参数表

    1
    2
    3
    4
    5
    6
    7
    void main(List<String> arguments) {
    print(arguments);

    assert(arguments.length == 2);
    assert(int.parse(arguments[0]) == 1);
    assert(arguments[1] == 'test');
    }
  • 函数是一等公民, 也就是说可看成是 lambda 表达式的一种表现形式而已.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    void printElement(int element) {
    print(element);
    }

    var list = [1, 2, 3];

    // Pass printElement as a parameter.
    list.forEach(printElement);

    var loudify = (msg) => '!!! ${msg.toUpperCase()} !!!';
  • 在 Dart 中写 Closure 的语法上有些许差异:

    1
    2
    3
    4
    5
    6
    7
    8
    var list = ['apples', 'bananas', 'oranges'];
    list.forEach((item) {
    print('${list.indexOf(item)}: $item');
    });

    // 还可以像 C# 那样搞成单行的:
    list.forEach(
    (item) => print('${list.indexOf(item)}: $item'));
  • 可以像下面这样返回 Closure: 注意 Function 类型的使用

    1
    2
    3
    Function makeAdder(num addBy) {
    return (num i) => addBy + i;
    }
  • 注意有个坑: 所有的函数都会返回值, 如果没有 return 语句, 则默认返回的是 null, 且会被自动添加到函数末尾, 除非显式标记为 void !!!!!!!!!!

操作符

除了 / 外, 如果对 double 使用 ~/, 则是整除的结果, 这样比较方便.

使用 == 判断两个对象的值是否相等, 如果要判断两个对象是不是同一个对象, 使用 identical() 方法来判断.

== 就是操作符重载的效果, 因为还可以在对象上调用它: .== ….

使用 as 进行类型转换, 没有可选类型, 也就没有 as? 了.

使用 isis! 来判断是否是某类型.

Flutter 在 null 上调用对象方法会抛出异常. 故一般需要在转型前进行判断.

使用 ??= 来赋值一次, 如果变量已经有值了, 则不再赋值.

使用 ?? 来提供 null 时候的默认值, 和 swift 一致.

可以使用 ?. 来可选访问.

流程控制

仍然是老格调, 需要 break, 如果不加, 默认就是 fallthrough 的.

异常

异常不需要显式写 throws, 也不是必须 catch 异常. 并且可以抛出普通对象.

捕捉异常的语法是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
try {
breedMoreLlamas();
} on OutOfLlamasException {
// A specific exception
buyMoreLlamas();
} on Exception catch (e) {
// Anything else that is an exception
print('Unknown exception: $e');
} catch (e) {
// No specified type, handles all
print('Something really unknown: $e');
} finally {
// Always clean up, even if an exception is thrown.
cleanLlamaStalls();
}

使用 on 来捕捉指定类型的异常, 使用 catch 来捕捉 Exception 或其子类. finally 处于链条最后, 执行善后工作, 比如释放资源等.

catch 语句中支持一个或两个参数, 两个参数时如下:

1
2
3
4
catch (e, s) {
print('Exception details:\n $e');
print('Stack trace:\n $s');
}

第二个参数代表调用栈.

一个非常好用的东西: rethrow!!!

如下所示, 使用 rethrow 可以将异常继续传播:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void misbehave() {
try {
dynamic foo = true;
print(foo++); // Runtime error
} catch (e) {
print('misbehave() partially handled ${e.runtimeType}.');
rethrow; // Allow callers to see the exception.
}
}

void main() {
try {
misbehave();
} catch (e) {
print('main() finished handling ${e.runtimeType}.');
}
}

其实貌似 swift 中也有, 只是叫做 rethrows.

所有的 class 都继承自 Object.

构造方法可以是 类名(), 也可以是 类名.构造方法名():

1
2
var p1 = Point(2, 2);
var p2 = Point.fromJson({'x': 1, 'y': 2});

使用常量构造函数来生成编译时常量:

1
2
3
4
var a = const ImmutablePoint(1, 1);
var b = const ImmutablePoint(1, 1);

assert(identical(a, b)); // They are the same instance!

使用 const 来定义常量, 即可生成一个常量上下文, 这样的话该类型的构造函数内所有的内容都是常量了:

1
2
3
4
5
6
7
8
9
10
11
12
// Only one const, which establishes the constant context.
const pointAndLine = {
'point': [ImmutablePoint(0, 0)],
'line': [ImmutablePoint(1, 10), ImmutablePoint(-2, 11)],
};

// 和下面的等价:

const pointAndLine = const {
'point': const [const ImmutablePoint(0, 0)],
'line': const [const ImmutablePoint(1, 10), const ImmutablePoint(-2, 11)],
};

使用 runtimeType 获取某个常量或变量的运行时类型, 返回值是 Type 类型的:

1
print('The type of a is ${a.runtimeType}');

构造方法

构造方法普通写法:

1
2
3
4
5
6
7
8
9
class Point {
num x, y;

Point(num x, num y) {
// There's a better way to do this, stay tuned.
this.x = x;
this.y = y;
}
}

语法糖写法:

1
2
3
4
5
6
7
class Point {
num x, y;

// Syntactic sugar for setting x and y
// before the constructor body runs.
Point(this.x, this.y);
}

另外可以写有名字的构造方法:

1
2
3
4
5
6
7
8
9
10
11
class Point {
num x, y;

Point(this.x, this.y);

// Named constructor
Point.origin() {
x = 0;
y = 0;
}
}

父类构造方法的调用类似 C#:

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
class Person {
String firstName;

Person.fromJson(Map data) {
print('in Person');
}
}

class Employee extends Person {
// Person does not have a default constructor;
// you must call super.fromJson(data).
Employee.fromJson(Map data) : super.fromJson(data) {
print('in Employee');
}
}

main() {
var emp = new Employee.fromJson({});

// Prints:
// in Person
// in Employee
if (emp is Person) {
// Type check
emp.firstName = 'Bob';
}
(emp as Person).firstName = 'Bob';
}

可以实现类似 swift 那种在访问 self 之前先把所有属性都初始化的方式:

1
2
3
4
5
Point.fromJson(Map<String, num> json)
: x = json['x'],
y = json['y'] {
print('In Point.fromJson(): ($x, $y)');
}

注意在冒号右边是无法访问 this 的, 因为此时还没有 this. 反正就是可以在冒号右边用逗号分隔若干个表达式, 这些表达式类似 swift 在调用 super.init 之前进行的.

调用自身的构造方法, 和 C# 类似:

1
2
3
4
5
6
7
8
9
class Point {
num x, y;

// The main constructor for this class.
Point(this.x, this.y);

// Delegates to the main constructor.
Point.alongXAxis(num x) : this(x, 0);
}

常量构造方法:

1
2
3
4
5
6
7
8
class ImmutablePoint {
static final ImmutablePoint origin =
const ImmutablePoint(0, 0);

final num x, y;

const ImmutablePoint(this.x, this.y);
}

需要保证所有的对象属性都是 final 的.

工厂构造方法(使用 factory 关键字): 一种特殊的构造方法, 它可能不会总生成新的对象:

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
class Logger {
final String name;
bool mute = false;

// _cache is library-private, thanks to
// the _ in front of its name.
static final Map<String, Logger> _cache =
<String, Logger>{};

factory Logger(String name) {
if (_cache.containsKey(name)) {
return _cache[name];
} else {
final logger = Logger._internal(name);
_cache[name] = logger;
return logger;
}
}

Logger._internal(this.name);

void log(String msg) {
if (!mute) print(msg);
}
}

由于 Dart 是单线程的, 所以也不存在创建单例的时候会出现创建两个的情况.

调用工厂构造方法的时候, 和普通的构造函数调用语法相同:

1
2
var logger = Logger('UI');
logger.log('Button clicked');

使用 setget 关键字定义某个域的 setter 和 getter, 这个可以理解为计算属性的写法不同而已:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Rectangle {
num left, top, width, height;

Rectangle(this.left, this.top, this.width, this.height);

// Define two calculated properties: right and bottom.
num get right => left + width;
set right(num value) => left = value - width;
num get bottom => top + height;
set bottom(num value) => top = value - height;
}

void main() {
var rect = Rectangle(3, 4, 20, 15);
assert(rect.left == 3);
rect.right = 12;
assert(rect.left == -8);
}

默认情况下系统会自动为域创建getter 和 setter(如果不是 final 域).

抽象类作接口又回来了!

1
2
3
4
5
6
7
8
9
10
11
abstract class Doer {
// Define instance variables and methods...

void doSomething(); // Define an abstract method.
}

class EffectiveDoer extends Doer {
void doSomething() {
// Provide an implementation, so the method is not abstract here...
}
}

而且结合 factory 构造方法, 可以在抽象类中实现提供子类对象的工厂方法.

Dart 中没有接口的概念, 但每个类都会隐式生成一个对应的接口, 接口内容就是所有的对象成员(属性和方法), 如果想让一个类实现另外一个类的接口, 而非继承, 则可以这样写:

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
// A person. The implicit interface contains greet().
class Person {
// In the interface, but visible only in this library.
final _name;

// Not in the interface, since this is a constructor.
Person(this._name);

// In the interface.
String greet(String who) => 'Hello, $who. I am $_name.';
}

// An implementation of the Person interface.
class Impostor implements Person {
get _name => '';

String greet(String who) => 'Hi $who. Do you know who I am?';
}

String greetBob(Person person) => person.greet('Bob');

void main() {
print(greetBob(Person('Kathy')));
print(greetBob(Impostor()));
}

这样还可以让一个类实现多个接口(感觉好像是多继承, 实际上只是实现接口而已):

1
class Point implements Comparable, Location {...}

继承的话, 使用 extends 关键字即可.

运算符重载的话, 可以这样写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Vector {
final int x, y;

Vector(this.x, this.y);

Vector operator +(Vector v) => Vector(x + v.x, y + v.y);
Vector operator -(Vector v) => Vector(x - v.x, y - v.y);

// Operator == and hashCode not shown. For details, see note below.
// ···
}

void main() {
final v = Vector(2, 3);
final w = Vector(2, 2);

assert(v + w == Vector(4, 5));
assert(v - w == Vector(0, 1));
}

重写 == 操作符的时候需要重写 hashCode 属性的 getter.

重写 noSuchMethod 方法可以提供对未实现方法的调用时候提供的信息.

enum 类型

简单定义:

1
2
3
4
enum Color { red, green, blue }
assert(Color.red.index == 0);
assert(Color.green.index == 1);
assert(Color.blue.index == 2);

默认成员的值从 0 开始.

可以通过 values 属性来获取所有 case 的列表.

MixIn

是否和扩展类似呢? 貌似就是和扩展一样, 不过比扩展更强, 因为它可以在扩展中写存储属性.

这个机制允许直接将两个 class 结合, 使用 with 关键字:

1
2
class A {}
class B with A {}

如果作为扩展的类不需要实例化, 则可以使用 mixin 关键字:

1
2
mixin A {}
class B with A {}

如果更符合扩展的特点, 则可以使用 on 关键字指定被扩展的类型:

1
2
mixin A on B {}
class B with A {}

类属性和类方法

使用 static 关键字, 和 swift 类似.

泛型

泛型集合类型

  • 列表: <String>[]
  • 字典: <String, String>{}

实际上等价于:

  • List<String>()
  • Map<String, String>()

实际上还有 Set<E> 类型.

泛型约束

1
2
3
4
5
6
7
8
9
10
11
class Foo<T extends SomeBaseClass> {
// Implementation goes here...
String toString() => "Instance of 'Foo<$T>'";
}

class Extender extends SomeBaseClass {...}

// 使用:

var someBaseClassFoo = Foo<SomeBaseClass>();
var extenderFoo = Foo<Extender>();

泛型方法

1
2
3
4
5
6
T first<T>(List<T> ts) {
// Do some initial work or error checking, then...
T tmp = ts[0];
// Do some additional checking or processing...
return tmp;
}

关于访问控制的问题, 加了下划线的定义是库内可见, 而不加的话是公共的.

异步操作

有两种选择:

  • asyncawait: 实际是 Future 的语法糖效果
  • Future 库和 Stream

使用 async/await 的时候, 返回 Future 对象:

1
2
3
4
5
6
7
8
Future checkVersion() async {
var version = await lookUpVersion();
// Do something with version
}

// 使用时:

await lookUpVersion();

如果有异常发生, 则仍然是和同步代码类似的处理方式:

1
2
3
4
5
try {
version = await lookUpVersion();
} catch (e) {
// React to inability to look up the version
}

若异步方法的返回值类型不是 Future, 返回值也会自动被包裹到 Future 中, 类似 C# 中的任务并行库中的 Task 的效果.

并且还支持一些比较新的效果, 比如异步的 main 函数:

1
2
3
4
Future main() async {
checkVersion();
print('In main: version is ${await lookUpVersion()}');
}

如果异步方法的确没有返回值, 最好是将其返回值写成 Future<void>.

产生序列的优化: 懒生成(类似 C# 的 yield)

可以使用如下两种方式懒生成: 实际上也是有一个 yield 关键字

  • 生成 Iterable 对象: 同步生成
  • 生成 Stream 对象: 异步生成

同步生成:

1
2
3
4
Iterable<int> naturalsTo(int n) sync* {
int k = 0;
while (k < n) yield k++;
}

异步生成:

1
2
3
4
Stream<int> asynchronousNaturalsTo(int n) async* {
int k = 0;
while (k < n) yield k++;
}

如果异步生成的时候还用到了递归, 则可以像下面这样写:

1
2
3
4
5
6
Iterable<int> naturalsDownFrom(int n) sync* {
if (n > 0) {
yield n;
yield* naturalsDownFrom(n - 1);
}
}

Dart 中的多线程机制: Isolate

详见这个链接.

typedef

1
2
3
4
5
6
// Initial, broken implementation.
int sort(Object a, Object b) => 0;

// 可以这样写:

typedef Compare = int Function(Object a, Object b);

还可以写泛型:

1
typedef Compare<T> = int Function(T a, T b);

Flutter

根据教程来搞这个: https://flutter.io/docs/get-started/codelab

  1. 使用 VS Code 初始化工程.

  2. 打开模拟器:

    1
    open -a Simulator
  3. 真机的配置和模拟器类似, 只是多安装一些支撑工具即可.在官网上面有.

    遇到一个坑, 如果真机和 Xcode 之前设置过无线连接的话, 则无法在 run 后保持和真机的持续连接.

  4. 配置国内的镜像, 否则无法获取三方库:

    专门有一篇官方的文档讲这个, 中国特色… 中文官方网站.

  5. 使用三方库:

    这个网站搜索三方库.

    添加三方库和 Carthage 类似, 就加一句话到 pubspec.yaml 文件中的 dependenciesdev_dependencies下面即可.

    比如:

    1
    english_words: ^3.1.5

    但暂时不知道这个库是如何被加入到工程并使用的.

    使用:

    1
    import 'package:english_words/english_words.dart';
  6. 字符串内插使用的是 $ 符号, 貌似和 C# 中的类似.

  7. 无状态的控件是不可变的, 即它们的属性无法被改变, 所有的值都是常量, 不能改变的意思是: 在运行时无法通过 xx.yy = zz 来改变它属性的值.

    有状态控件会去维护它的状态, 它的状态可能在自己的生命期内改变. 要实现有状态控件, 需要一个 StatefulWidget 类型来创建控件对象, 另外还需要一个 State 类, 即状态类. 实际上有状态控件本身仍然是不可变的, 只是有了一个状态来维持它当前的状态.

  8. 列表控件的性能: 是因为使用的 JIT, 尝试在 Release 下面编译安装测试

    先在终端执行: flutter build ios 默认情况下就是 release 模式下编译的.

    然后在 Xcode 中选择 Generic iOS Device 然后选择打包, 后续的步骤和普通 APP 一致.

    打包出来的 ipa 可以直接在 Xcode 的 Organizer 中拖进去就安装到手机了… 这个是个神技..

    果然列表性能非常好.!!!!

  9. 在 Flutter 的世界中, Widget 类似 UIView, 但它们不会将自己直接渲染到屏幕上, Widget 只是用于描述视图. 且 Widget 不可变, 如果要拥有可变的 Widget, 唯一的途径就是给它们携带一个 State 对象. 在 State 中携带有当前 Widget 进行显示的内容, 故在 hot reload 的时候, 拥有 state 的 Widget 不会由于刷新而丢失数据.

    在视图更新时, 框架会把需要更新的 Widget 替换掉. 而不会像 iOS 那样直接改变 View.

  10. Flutter 可以和原生进行交互, 即相互调用. 通过的是消息通道, 类似 Android 中的 EventBus.

  11. 新建工程可以这样: flutter create -i swift 工程名称

    默认不设置的时候, 使用的是 OC.

    具体可以看原生和 Flutter 交互.

  12. 越来越觉得 State 对象就是 Widget 的一个 viewmodel, 且已经做好了绑定了. 通过在 setState 方法内提供块参数修改 State 的内容, 就可以改变绑定的 Widget 的显示.

  13. Flutter 的 hot reload 是通过 JIT 编译提供的!

  14. 和原生交互的时候都是异步进行的.

一些特征

  1. 默认情况下, 苹果风格的导航栏默认就是标题居中, 而安卓风格的需要设置AppBarcenterTitle属性.

    一个典型的 APP Bar 实现如下所示(里面使用苹果风格的按钮避免出现inkwell效果):

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    AppBar _buildAppBar() {
    return AppBar(
    elevation: 1.0,
    centerTitle: true,
    title: Text('SHRINE'),
    leading: CupertinoButton(
    pressedOpacity: 0.8,
    child: Icon(Icons.menu),
    onPressed: _menuPressed,
    ),
    actions: <Widget>[
    CupertinoButton(
    pressedOpacity: 0.8,
    child: Icon(Icons.search),
    onPressed: _searchPressed,
    ),
    CupertinoButton(
    pressedOpacity: 0.8,
    child: Icon(Icons.explore),
    onPressed: _explorePressed,
    ),
    ],
    );
    }
  2. 网上介绍有一种思路可以是用 Flutter 写插件, 这样可以最大程度利用原生业务代理.


Flutter 和 Dart 概述
https://blog.rayy.top/2019/01/06/2019-23-flutter-and-dart/
作者
貘鸣
发布于
2019年1月6日
许可协议