I'm Terrence


  • 首页

  • 归档
I'm Terrence

Flutter 实战系列:await 在工程中实际应用的一些思考

发表于 2021-01-30

0x01

这个系列有一段时间没更新了,最近在业务编码过程中,有一些想法,于是把其中的点滴记录下来,跟大家分享下。

0x02 callback

在客户端开发中,日常会遇到很多 callback, delegate 之类的回调,所以当转过来到 dart 开发也可能会理所当然的习以为常了。我们来看下项目中这种情况:

先以 OC 层的代码说一下上下文:
Thunder的进入房间是异步的,通过 joinRoom 触发,然后通过 ThunderEventDelegate 的 onJoinRoomSuccess 来回调结果。

1
- (void)thunderEngine:(ThunderEngine *)engine onJoinRoomSuccess:(NSString *)room withUid:(NSString *)uid elapsed:(NSInteger)elapsed

同样,退房间的结果也是异步的,通过 leaveRoom 触发,然后通过 ThunderEventDelegate 的 onLeaveRoomWithStats

1
- (void)thunderEngine:(ThunderEngine *)engine onLeaveRoomWithStats:(ThunderRtcRoomStats * _Nonnull)stats

无可厚非,在客户端开发中,这种 delegete 回调方式是很正常不过的,因为这个ThunderEventDelegate的存在, 与直接在函数里面加callback 相比,这样让业务层可以自主去赋值,自定义去对象去处理这些回调,给业务层提供解耦的机会,就不用把所有逻辑都堆在同一个文件里面。

以上角度是咱们在 OC 开发者的角度来看的。

继续以 Thunder 进退房间为例子,业务日常开发的逻辑,往往是实现比当初设计要复杂得多的,比如:业务存在某种场景,需要先退房间A,再进房间B。
由于退房间的过程是异步的,如果继续以客户端的思维去编码的话,就必须把进房间的流程,堆在 onLeaveRoomWithStats 退房间完成的回调里面了,而其他业务正常的退房间,又不用进房间的,于是乎,onLeaveRoomWithStats 里面的逻辑会慢慢多起来。

1
2
//退房间A
[self leaveRoom]
1
2
3
4
5
6
7
8
9
10
11
//回调
- (void)thunderEngine:(ThunderEngine *)engine onLeaveRoomWithStats:(ThunderRtcRoomStats * _Nonnull)stats
{
if (needJoinRoomB) {
//joinRoomB logic
[self joinRoom:B];
} else {
//log
// do nothing
}
}

当新人来读代码的时候,要了解 joinRoomB logic 的时候,又得跳到 onJoinRoomSuccess 看,在进入房间后到底做了什么

1
2
3
4
- (void)thunderEngine:(ThunderEngine *)engine onLeaveRoomWithStats:(ThunderRtcRoomStats * _Nonnull)stats {
//join Room success logic
[self openMic];
}

说实话,这样的代码阅读起来由于跨度比较大(累)的,逻辑分离的比较厉害。

当我们转到 dart 开发的时候,可以想一下,这种回调的方式是否是 dart 语言的最佳实践呢?

0x03 await kills callback

dart 因为 await 的存在,我们可以把很多回调式的写法,转换成流式。继续用刚刚进退房间那个做例子,来看看dart的写法可以简化到一种怎么样的程度:

1
2
3
4
5
6
7
8
//leaveRoom A
await koThunder.leaveRoom();

//joinRoom B
await koThunder.joinRoom(B);

//join Room success logic
openMic();

没错,就是这么简单,3行搞定···,业务上层完全脱离了回调来编程了,逻辑很紧凑,阅读起来会很舒服。

这个 koThunder 是我们业务对 FlutterThunder 的隔离层,在里面,我们对回调的方式进行了处理。
以 leaveRoom 为例吧:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//KoThunder 

Completer<bool> _leaveRoomCompleter;


Future<bool> leaveRoom() async {
ALog.info(_tag, "call leaveRoom");

_leaveRoomCompleter = Completer();
int res = await FlutterThunder.leaveRoom();
if (res != 0) {
ALog.info(_tag, "leaveRoom failed");
_leaveRoomCompleter.complete(false);
} else {
ALog.info(_tag, "wait leaveRoom callback");
}

return _leaveRoomCompleter.future;
}

@override
void onLeaveRoomWithStats() {
_leaveRoomCompleter.complete(true);
}

joinRoom 的处理操作也同理,这里不重复了。

原理很简单,无非就是用 Completer 来处理回调,向外统一暴露一个 Future 的东西就好了。这样写,个人觉得十分的 dart 化。把复杂留给自己,简单暴露给调用方,这样的代码,让业务上层的逻辑更容易阅读,更容易维护。

0x04 总结

我们在公司开发 flutter plugin 也有一段时间了,但很多时候都是按葫芦画瓢,对底层sdk 做一层dart层的封装, 能调用就完事了,并没有真正地把 dart 的语言特性发挥出来,这个是做得很不够的地方。唯有真正深入业务,理解业务的复杂,并把相关语言特性发挥出来,才能写出更好用的基础组件。

I'm Terrence

ffmpeg 实操

发表于 2020-10-12

0x1背景

前段时间去三亚深潜了,因为考证回来后第一次的 FD, 为了记录下所见的海底世界,临出发前在淘宝租了台 go pro 7。在整个使用过程中,除了2次无故的死机外,拍得还是挺顺心的。然后回来得把sd卡的视频转移到自己的电脑,这才发现,几天下来的视频已达到 80多g (一个 8 min 的视频就8g了),md,我的mac book 才多少g啊,哪吃得下!
因此把这些视频压缩这个任务优先级已经很高了,在这个契机下,我去复习了下视频编解码相关的知识。

0x2 视频压缩(编码)

所谓视频,其实就是很多张图片,在一定时间内轮播完。比如,如果视频的帧率是24帧的,就是1秒内轮播完24张图片。go pro 的帧率在默认模式去到了60帧了,还能不大吗··
然后这里的压缩,就可以分为两种了,一种是帧内压缩,即图片压缩,另一种是帧间压缩,简要说来就是对于前后两帧,编码后面那帧时,只把基于前面那一帧不同的地方给编码,这样对于前后两帧差别不大的地方,压缩率是很高的。

0x2-1 ffmpeg

ffmpeg 是一套功能强大的,开源的,音视频处理工具,功能包括但不限于包括视频采集功能、视频格式转换、视频抓图、给视频加水印等。

0x2-2 码率, 帧率,分辨率

帧率 (FPS):1s 中传输图片的的数量,主要影响视频的流畅度,帧率越高,流畅度越好,帧率越低,画面就越有跳跃感。视频一般的帧率有24就很流畅了,而 go pro 采集的时候有两种模式去到帧率去到 60 才有稳定的功能,导出的视频能不大才怪

分辨率:图片的尺寸,分辨率越大,图片的尺寸就越大了

码率:每 s 图片压缩后的数据量,而码率 x 时长就是视频的体积了。分辨率 x 帧率 = 每 s 在压缩前的数据量。

在码率一定的情况下,分辨率与图像清晰度成反比关系,分辨率越大,图像就越不清晰。

在分辨率一定的情况下,码率与图像清晰度成正比关系,码率越大,图像就越清晰。

0x2-3 压缩效果

可以尝试在帧率,分辨率,码率三个维度作下牺牲,来改变视频的体积。

-r 改 帧率
ffmpeg -i input -r 24

-s 改 分辨率
ffmpeg -i input -s 640x480

-b:v 改 码率

ffmpeg -i input -b:v 64k

直接修改其中一个或多个参数,都可以来打到压缩体积的效果,但画质惨不忍睹啊,由于硬盘容量实在有限,原视频在写这篇日志之前已经删光了,无法上图对比··

0x2-4 h265

ffmpeg -i input 这句可以看到原来的视频已经是用了 h264 编码的了,业界其实还有一种更先进的 h265 编码,立刻去尝试下!

安装 ffmpeg 这一块就不在这里详述了,这里主要在用 ffmpeg 进行 h265 编码遇到过的坑。

H265 需要借助 libx265 的库,而通过 brew 的方式来安装 ffmpeg 的话,由于 homebrew 本身是不支持带有 options 的方式安装 ffmpeg , 所以这种方式无法一步到位地把 ffmpeg 跟 libx265 关联起来,这个时候,需要借助 homebrew-ffmpeg 这个第三方库来给 ffmpeg 添加options 及 特性,而 这个库默认把 libx265 的能力添加进来了。

按步骤执行以下几句:

1
2
3
4
5
6
7
8
brew install ffmpeg

brew tap homebrew-ffmpeg/ffmpeg

brew install homebrew-ffmpeg/ffmpeg/ffmpeg

//关联 ffmpeg
brew link ffmpeg

至此, 命令行的 ffmpeg 就有了 libx265 的能力了

1
ffmpeg -i /Users/mac/Desktop/mp4/GH010105.MP4 -c:v libx265  /Users/mac/Documents/newFiles/GH010105_h265_1.mp4

原体积 8g 的 时长 8min的 MP4文件,经 h265 转换后,体积为 300M+,视频展示效果从肉眼上已经很难分辨出有什么不同,经过老婆的检验,清晰度是 ok 的。唯一有点不足的是,转换时间太长了,转换60+个文件,用下面这个批量脚本去跑,跑了一整天才全部完成··

批量转 h265

要转视频实在太多了,于是用 python 写了个批量转换 h265 的脚本

1
python /Users/mac/Documents/pythonTest/test.py /Users/mac/Desktop/mp4  /Users/mac/Desktop/mp4Output

指定输入文件夹,输出文件夹即可

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
#!/usr/bin/python
# -*- coding: UTF-8 -*-

import os
import sys


def getFilesIn(path) :
g = os.walk(path)
sourceFileList = []
for path,dir_list,file_list in g:
for file_name in file_list:
sourceFileList.append(os.path.join(path, file_name))
return sourceFileList

# ffmpeg -i /Users/mac/Desktop/mp4/GH010105.MP4 -c:v libx265 /Users/mac/Documents/newFiles/GH010105_h265_1.mp4
def convertToH265(file):
print("############")
outputFilePath = outputPath +'/'+ os.path.basename(file)
print("输出文件:"+outputFilePath)
print(file)
print("=======")
print(os.system('ffmpeg -i ' + str(file) + " -c:v libx265 " + outputFilePath))

args = sys.argv
print (args[1])
inputPath = args[1]
outputPath = args[2]

print("输入路径:"+inputPath)
print("输出路径:"+outputPath)


for file in getFilesIn(inputPath):
print("开始转换: "+file+" basename: "+os.path.basename(file))
convertToH265(file)

视频合并

这个需求是来源于从go pro 导出的时长较长的mp4,都被分拆出好几个duration 是有 8 min 的子文件了,在解决完压缩问题后,我们在看看要怎么把分拆过的视频合并起来。

方法1

ffmpeg -i "concat:FD1-1.MP4|FD1-2.MP4|" -c copy FD1-con.mp4

但是失败,结果还是只有FD1-1的内容,见有warning
Found duplicated MOOV Atom. Skipped it

输出只含有第一个文件的信息,我们再用方法2试下

方法2

  1. 创建一个文件 concatFile.txt,把需要合并的文件名称写进去:
1
2
3
4
5
file 'FD2-1.MP4'
file 'FD2-2.MP4'
file 'FD2-3.MP4'
file 'FD2-4.MP4'
file 'FD2-5.MP4'
  1. 执行

    ffmpeg -f concat -i concatFile.txt -c copy fd2output.mp4

查看输出结果 fd2output.mp4, 已成功合并了。

总结

学习技术, 无非是为了解决实际问题,技术再好,境界越高,若未能落地应用,也是空中楼阁而已。这次能通过编码的方式来解决来源于生活的问题,有作为技术人的一种自豪感啊。

I'm Terrence

Swift 学习笔记

发表于 2020-09-23

.self 跟 .Type 傻傻分不清

Int.Type 是 Int 的元类型(),而 Int.Type 跟 Int.self 的关系,就是 Int 跟 5 的关系,一个是类型,一个是值

什么是元类型?

我们通过元类型,去调用这个类的 static 方法,个人感觉,有点像 oc 被类对象的 isa 指针所指向的 meta Class

1
2
3
4
5
6
7
Int.max

//实际上等价于:

Int.self.max

//只是编译器帮我们省去了这个self

protocol.Type?

首先 protocol 不是一个类型,只有他被一个类实现了,才具有元类型这个说法

show me the code

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
54
55
56
57
58
59
60
61
62
63
64
65


protocol MYStrategy {
func cal() -> Int
}


class PlanA : MYStrategy {
required init() {

}
func cal() -> Int {
return 1
}
}


class PlanB : MYStrategy {
required init() {

}
func cal() -> Int {
return 2
}
}

struct PlanFactory {

/*
根据类型推断,免去传参的烦恼
*/
static func createPlan<T: MYStrategy>() -> T? {
if let planA = T.self as? PlanA.Type {
return planA.init() as? T
}
if let planB = T.self as? PlanB.Type {
return planB.init() as? T
}
return nil
}

/*
type 传入的是一个元类型的值,由于 MYStrategy 是 protocol,这里传一个实现了 MYStrategy 的类的元类型的值即可
*/
static func createPlanV2(type: MYStrategy.Type) -> MYStrategy? {
if type == PlanA.self {
return PlanA()
}

if type == PlanB.self {
return PlanB()
}
return nil
}
}



class MainTest {
func test() {
var plan:PlanA? = PlanFactory.createPlan()

var planT = PlanFactory.createPlanV2(type: PlanA.self)
}
}

type(of:) 跟 .self

相同点:都是获取 metaType (元类型)

不同点:type(of: value), 其中参数value 是个对象实例,主要用于动态获取 value 的 元类型;而 XXX.self 是静态获取 XXX 的元类型,其中 XXX 是个类

1
2
3
4
var testString = "123"
var a = type(of: testString) //String
var b = String.self //String
var c = testString.self // 123

Self

在协议中用得比较多用来表示遵循这个协议的对象
这里用例子自定义命名空间的例子来说明一下(仿照rxSwift)

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
54
55
56
57
58
59
60
61
/*
这个 struct 作为命名空间 Xr
Base 是泛型
*/
public struct Xr<Base> {
public let base: Base
public init(_ base: Base) {
self.base = base
}
}

/*
协议 + 关联类型
一个静态 getter, 一个实例 getter
*/
public protocol XrCompatible {
associatedtype XrBase
static var xr: Xr<XrBase>.Type { get }
var xr: Xr<XrBase> { get }
}

/*
扩展 XrCompatible,写默认实现
限定 Base 类型为 Self
*/
extension XrCompatible {

//这里的几个 Self, 是给 Xr 的<Base> 泛型做了限制,限制为遵循这个协议的对象本身

//静态 xr getter,用于扩展类方法
public static var xr: Xr<Self>.Type {
get { Xr<Self>.self }
}

//实例 xr getter,用于扩展实例方法
public var xr: Xr<Self> {
get { Xr(self) }
}
}


/*
赋予以下类 XrCompatible 的能力,可以直接用默认实现的两个 getter 来玩,让他们有了 XX.xr 的命名空间
*/
extension NSObject: XrCompatible { }
extension String: XrCompatible { }
extension Data: XrCompatible { }


/*
对 Xr 且 base 是 String 类型的进行扩展,新增方法 appendHaha
*/
extension Xr where Base == String {
func appendHaha() -> String {
return base+"haha"
}
}


let testString = "123".xr.appendHaha()
// 123haha
I'm Terrence

MobX Flutter 数据流动原理篇

发表于 2020-06-30

背景

MoTouch 项目中的状态管理大部分是基于 MobX 的, 使用方法就不在这里说了,详见 MobX官网。

使用过的同学们都知道,当 Observer builder 里面某个 MobX 属性发生改变时,就会自动刷新 Observer 了。但我一直有个疑问,为什么数据不用进行显式的绑定,到底是在哪里进行绑定的?如何才能确定某个属性已经被正确监听了?带着这些疑问,我们一起学习下源码。

类结构分工

Atom

被观察对象
Atom 对象是由 xxx.g.dart 生成, 实际上,我们每标记一个 @observable 属性,在 .g.dart 生成 getter 跟 setter 及对应的 atom, 通过 _atom.reportRead() 和 _atom.reportWrite() 来触发 ReactiveContext 的数据绑定及分发。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import 'package:mobx/mobx.dart';

part 'counter.g.dart';

class Counter = _Counter with _$Counter; //外面实例化的是这个 Counter, 实际上 是把_$Counter 这个 Mixin 实例化了

abstract class _Counter with Store {
@observable
int value = 0;

@observable
ObservableMap<String, Size> uidVideoSizeMap = ObservableMap();

@action
void increment() {
value++;
}
}
1
2
3
4
5
6
7
8
9

part of 'counter.dart';

mixin _$Counter on _Counter, Store {// _$Counter 又继承了_Counter
final _$valueAtom = Atom(name: '_Counter.value');
···
final _$uidVideoSizeMapAtom = Atom(name: '_Counter.uidVideoSizeMap');
···
}

Observer

0.3.8

项目最早是用 0.3.8 版本的,很有印象,Observer 其核心就是个 StatefulWidget 而已,一旦数据变化,内部通过调用 setState(), 触发 State 的刷新,从而触发 builder 的刷新。

1
void invalidate() => setState(noOp);

1.1.0

而到了 1.1.0 版本,Observer 的实现就有所不同了,不再是个StatefulWidget, 而是个 StatelessWidget了。主角是 elemtent , 核心通过

1
void invalidate() => markNeedsBuild();

标记 当前 element 为 dirty, 在下一帧触发相应的 build 方法。

Reaction(ReactionImpl,Derivation)

被 Observer 持有,封装数据绑定,更新回调方法。Reaction.run 回调给 Observer

ReactiveContext

1
final ReactiveContext mainContext = createContext(config: ReactiveConfig.main);

是个巨大的单例,负责处理 Atom 跟 Reaction 的依赖关系, 及进行数据方法绑定、分发、解绑等逻辑。

数据流动过程

数据绑定

整个数据绑定过程,在 0.3.8 版本是发生在 Observer State 的 build 里面,而在 1.1.0 版本,是在 Observer Element 的 build 方法体内。

1. start tracking

在 ReactiveContext 单例记录当前的 derivation。

1
2
3
4
5
6
7
8
9
Derivation _startTracking(Derivation derivation) {
final prevDerivation = _state.trackingDerivation;
_state.trackingDerivation = derivation;

_resetDerivationState(derivation);
derivation._newObservables = {};

return prevDerivation;
}

2. reportObserved()

image-20200623114315072

看堆栈可以知道,对于每一次 @Observable 对象的 get 调用,实际上是 atom.reportObserved() ,最终调用ReaciveContext 的 _reportObserved。

1
2
3
4
5
6
7
8
9
10
11
12
13
void _reportObserved(Atom atom) {
final derivation = _state.trackingDerivation;

if (derivation != null) {
// 把 atom 加到当前的 derivation 的新观察队列里面
derivation._newObservables.add(atom);
if (!atom._isBeingObserved) {
atom
.._isBeingObserved = true
.._notifyOnBecomeObserved();
}
}
}

3. endTracking

image-20200622143205460

把在 startTracking 跟 endTracking 之间, 所有被调用 reportRead() 的 atom, 绑定当前观察者 derivation 。

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
void _bindDependencies(Derivation derivation) {
// 这里对 Set 搞了两次difference, 目的是把新、旧 atoms 分开。旧的清空数据,新的绑定观察者
final staleObservables =
derivation._observables.difference(derivation._newObservables);
final newObservables =
derivation._newObservables.difference(derivation._observables);
var lowestNewDerivationState = DerivationState.upToDate;

// Add newly found observables
for (final observable in newObservables) {
observable._addObserver(derivation);

// Computed = Observable + Derivation
if (observable is Computed) {
if (observable._dependenciesState.index >
lowestNewDerivationState.index) {
lowestNewDerivationState = observable._dependenciesState;
}
}
}

// Remove previous observables
for (final ob in staleObservables) {
ob._removeObserver(derivation);
}

if (lowestNewDerivationState != DerivationState.upToDate) {
derivation
.._dependenciesState = lowestNewDerivationState
.._onBecomeStale();
}

derivation
.._observables = derivation._newObservables
.._newObservables = {}; // No need for newObservables beyond this point
}

数据更新

reportWrite()

当数据更新 atom.reportWrite() 主要做了这两件事:

  1. 更新 数据
  2. 把 与之绑定 derivation (即 reaction) 加到队列。
1
2
3
4
5
6
7
8
9
10
11
12
13
void reportWrite<T>(T newValue, T oldValue, void Function() setNewValue) {
context.spyReport(ObservableValueSpyEvent(this,
newValue: newValue, oldValue: oldValue, name: name));

// ignore: cascade_invocations
context.conditionallyRunInAction(() {
setNewValue();
reportChanged();//触发 Context.addPendingReaction(reaction)
}, this, name: '${name}_set');

// ignore: cascade_invocations
context.spyReport(EndedSpyEvent(type: 'observable', name: name));
}
1
2
3
4
// 把 reaction 添加到队列, 这里 reaction 就是 ReactionImpl
void addPendingReaction(Reaction reaction) {
_state.pendingReactions.add(reaction);
}

数据分发

@action

image-20200622151359216

不带 @action

image-20200622152046571

带不带 @action 的区别,其实就是 下面这个地方有没把 ActionController 传入来,数据流向其实是一样的,都会由 controller.endAction(runInfo); 来触发。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void conditionallyRunInAction(void Function() fn, Atom atom,
{String name, ActionController actionController}) {
if (isWithinBatch) {
enforceWritePolicy(atom);
fn();
} else {
final controller = actionController ??
ActionController(
context: this, name: name ?? nameFor('conditionallyRunInAction'));
final runInfo = controller.startAction();

try {
enforceWritePolicy(atom);
fn();
} finally {
controller.endAction(runInfo);
}
}
}
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
void _runReactionsInternal() {
_state.isRunningReactions = true;

//从队列读取 reaction
var iterations = 0;
final allReactions = _state.pendingReactions;


while (allReactions.isNotEmpty) {
// 这里是抛出死循环的情况
if (++iterations == config.maxIterations) {
final failingReaction = allReactions[0];

// Resetting ensures we have no bad-state left
_resetState();

throw MobXCyclicReactionException(
"Reaction doesn't converge to a stable state after ${config.maxIterations} iterations. Probably there is a cycle in the reactive function: $failingReaction");
}

final remainingReactions = allReactions.toList(growable: false);
allReactions.clear();
for (final reaction in remainingReactions) {
reaction._run();//分发,回调给 Observer 层
}
}

_state
..pendingReactions = []
..isRunningReactions = false;
}

最终触发 rebuild

0.3.8

1
2
3
4
//observer.dart
class ObserverState extends State<Observer> {
void invalidate() => setState(noOp);
}

1.1.0

1
2
3
4
5
6

//observer_widget_mixin.dart
mixin ObserverElementMixin on ComponentElement {
//reaction.run 回调给 Observer 层,通过 markNeedsBuild 触发 rebuild
void invalidate() => markNeedsBuild();
}

析构

因为 MobX 里面存在一个 ReactiveContext 单例,那就涉及到对数据的清除绑定了

image-20200622164901647

1
2
3
4
5
6
7
8
9
10
11
void _clearObservables(Derivation derivation) {
final observables = derivation._observables;
derivation._observables = {};

for (final x in observables) {
//打破 atom 跟 reaction 的双向依赖
x._removeObserver(derivation);
}

derivation._dependenciesState = DerivationState.notTracking;
}

应用

在实际开发过程中,我们项目会遇到一些数据更新了,但没触发 Observer rebuild 的一些疑问,在弄清楚数据流向后,现在可以基本解决了。

Counter 还是用上文那个例子,我们这次是对其中MobX 提供的 ObservableMap 进行监听,意图监听 map 的增减操作。

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
54
55
56
57
58
59

import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:mobx/mobx.dart';
import 'package:motouch/UI/Me/counter.dart';

class CounterExample extends StatefulWidget {
const CounterExample();

@override
CounterExampleState createState() => CounterExampleState();
}

class CounterExampleState extends State<CounterExample> {
final Counter counter = Counter();

@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(
backgroundColor: Colors.blue,
title: const Text('MobX Counter'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Observer(
builder: (_) {
//bind 1
counter.uidVideoSizeMap;

//bind 2
// counter.uidVideoSizeMap.length;

print('rebuild');
return Container();
},
),
MaterialButton(
child: Text('对 counter.uidVideoSizeMap 重新赋值'),
onPressed: () {
//update 1
print('对 counter.uidVideoSizeMap 重新赋值');
counter.uidVideoSizeMap = ObservableMap();
},
),
MaterialButton(
child: Text('counter.uidVideoSizeMap 增减元素'),
onPressed: () {
//update 2
print('counter.uidVideoSizeMap 增减元素');
counter.uidVideoSizeMap['1'] = Size.zero;
},
),
],
),
),
);
}

分绑定分2种方式,我们先注释 bind 2,用 counter.uidVideoSizeMap绑定,看看打印结果:

1
2
3
4
5
6
7
8
I/flutter (20534): 对 counter.uidVideoSizeMap 重新赋值
I/flutter (20534): rebuild
I/flutter (20534): 对 counter.uidVideoSizeMap 重新赋值
I/flutter (20534): rebuild
I/flutter (20534): 对 counter.uidVideoSizeMap 重新赋值
I/flutter (20534): rebuild
I/flutter (20534): counter.uidVideoSizeMap 增减元素
I/flutter (20534): counter.uidVideoSizeMap 增减元素

可见,bind 1 这种方式,增减元素是不会引起 Observer 的 rebuild 的。

再来看看注释 bind 1, 打开 counter.uidVideoSizeMap.length的结果

1
2
3
4
5
6
7
I/flutter (20534): 对 counter.uidVideoSizeMap 重新赋值
I/flutter (20534): rebuild
I/flutter (20534): 对 counter.uidVideoSizeMap 重新赋值
I/flutter (20534): rebuild
I/flutter (20534): counter.uidVideoSizeMap 增减元素
I/flutter (20534): rebuild
I/flutter (20534): counter.uidVideoSizeMap 增减元素

bind 2 的方式,无论是重新赋值,还是增减元素,都能引起 Observer 的 rebuild 。

结合源码来分析,bind 1这种绑定方式:

1
2
3
4
5
6
7
8
9
//counter.g.dart
// 在 Observer builder 方法体内,每次 counter.uidVideoSizeMap, 触发的是 _Counter.uidVideoSizeMap 这个属性 atom 的 reportRead(), 绑定的是这个属性本身,跟 ObserverMap 的类型无关。
final _$uidVideoSizeMapAtom = Atom(name: '_Counter.uidVideoSizeMap');

@override
ObservableMap<String, Size> get uidVideoSizeMap {
_$uidVideoSizeMapAtom.reportRead();//内里调用 reportObserver()
return super.uidVideoSizeMap;
}

bind 2:

1
2
3
4
5
6
7
8
9
//observable_map.dart 
// 在 Observer builder 方法体内,调用 counter.uidVideoSizeMap.length,是把 Observer_map 里面实现 的 _atom 给绑定了。
@override
int get length {
_context.enforceReadPolicy(_atom);

_atom.reportObserved();//绑定
return _map.length;
}

可见, bind 1, bind 2两种绑定方式,决定了 reaction 的不同, bind 1 那种方式完全把 uidVideoSizeMap 当成普通类型来用了,压根没有把 ObservableMap 类型带给我们便利给用上。

ObservableList, ObservableSet 也同理。

I'm Terrence

Flutter 实战系列: 记一次视频区域黑屏问题分析与解决

发表于 2020-03-03

背景

咱们 MoTouch 项目中的直播间内的视频区域是通过 flutter platform view + Thunder SDK 实现的,而在开发和测试过程中,iOS 侧频频出现莫名其妙的黑屏问题,而且是整个远端视图都黑了,分析日志后,发现: thunder 的远端流通知跟远端视图的首帧回调到来了,但视频区域还是黑的,怀疑是由于业务层调用接口不恰当引起的,这里记录一下问题的分析和解决。

分析原因

哪里黑屏了

既然 thunder 那边首帧回调都过来了,大概率是业务层这边的不恰当处理了···第一反应是这个这个 platform view 到底有没加上视图? frame 到底对不对?
等在 debug 复现黑屏情况后,在 xcode 看过视图层级,发现这个时候的 frame,size 都没问题,后来还是在 SDK 的同学帮忙排查下,才发现,是在调用 FlutterThunder.setRemoteVideoLayout 的时候,view 的 frame 是 0,导致 sdk 这个 video 的 frame 是 0 了,即使后来 view 的 frame 对了,但是没更新到 FlutterThunder 那边。

1
2
3
4
5
6
7
8
9
Widget _remoteMixinWidget() {
if (_remoteViewWidget == null) {
_remoteViewWidget = FlutterThunder.createNativeView((viewId) {
remoteViewId = viewId;
FlutterThunder.setRemoteVideoLayout(...)// 这句调用时机不对, platform view 的 frame 还是 0
}, key: _remoteKey);
}
return _remoteViewWidget;
}

frame 为啥是 0 ?

翻翻 engine 的源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//FlutterPlatformViews.mm

void FlutterPlatformViewsController::OnCreate(FlutterMethodCall* call, FlutterResult& result) {
...

NSObject<FlutterPlatformView>* embedded_view = [factory createWithFrame:CGRectZero
viewIdentifier:viewId
arguments:params];// 这个 CGRectZero 是engine 自己塞的
views_[viewId] = fml::scoped_nsobject<NSObject<FlutterPlatformView>>([embedded_view retain]);

FlutterTouchInterceptingView* touch_interceptor = [[[FlutterTouchInterceptingView alloc]
initWithEmbeddedView:embedded_view.view
flutterViewController:flutter_view_controller_.get()
gestureRecognizersBlockingPolicy:gesture_recognizers_blocking_policies[viewType]]
autorelease];// 塞进 FlutterTouchInterceptingView

touch_interceptors_[viewId] =
fml::scoped_nsobject<FlutterTouchInterceptingView>([touch_interceptor retain]);
root_views_[viewId] = fml::scoped_nsobject<UIView>([touch_interceptor retain]);

result(nil);
}

可见 platform view 初始化出来时,肯定是 CGRectZero 的。

但什么时候才是正确的呢?

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
- (instancetype)initWithEmbeddedView:(UIView*)embeddedView
flutterViewController:(UIViewController*)flutterViewController
gestureRecognizersBlockingPolicy:
(FlutterPlatformViewGestureRecognizersBlockingPolicy)blockingPolicy {
self = [super initWithFrame:embeddedView.frame];
if (self) {
self.multipleTouchEnabled = YES;
embeddedView.autoresizingMask =
(UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight);

[self addSubview:embeddedView];

ForwardingGestureRecognizer* forwardingRecognizer =
[[[ForwardingGestureRecognizer alloc] initWithTarget:self
flutterViewController:flutterViewController] autorelease];

_delayingRecognizer.reset([[DelayingGestureRecognizer alloc]
initWithTarget:self
action:nil
forwardingRecognizer:forwardingRecognizer]);
_blockingPolicy = blockingPolicy;

[self addGestureRecognizer:_delayingRecognizer.get()];
[self addGestureRecognizer:forwardingRecognizer];
}
return self;
}

我们都看得出来, FlutterPlatformView 实际上是被一个 FlutterTouchInterceptingView 包住的,其 frame 是跟随 FlutterTouchInterceptingView 大小

所以,我们顺藤摸瓜,看看 FlutterTouchInterceptingView 的 frame 到底在哪里改正确的。

1
2
3
4
5
6
7
8
void FlutterPlatformViewsController::CompositeWithParams(int view_id,
const EmbeddedViewParams& params) {
CGRect frame = CGRectMake(0, 0, params.sizePoints.width(), params.sizePoints.height());
UIView* touchInterceptor = touch_interceptors_[view_id].get();
touchInterceptor.layer.transform = CATransform3DIdentity;
touchInterceptor.frame = frame; // 就是这里了
touchInterceptor.alpha = 1;
}

解决方案

addPostFrameCallback?

讲真,我们在 flutter 层其实对 native 的 frame 操作基本上是没有了,来看下我们都是怎么设置 platformView 的大小跟坐标的

1
2
3
4
5
6
7
8
9
10
11
12
Widget remoteView() {
return Positioned(
top: 0,
left: 0,
child: Container(
color: Colors.black,
width: 100,
height: 100,
child: remoteMixinView,//platformview
),
);
}

容易看出来,通过对 platform view 的 父亲节点(Container 之类)设置坐标宽高,让其跟随父亲节点的大小。

那这个 platform view 的 宽高跟坐标在 flutter 层什么时候才确定呢?

google 一下, 建议是在这个每帧回调里面打印:

1
2
3
4
5
WidgetsBinding.instance.addPostFrameCallback((_) {
RenderBox renderBox = _key.currentContext.findRenderObject();
print(
'${renderBox.size}, ${renderBox.localToGlobal(Offset.zero)}');
});

这个 WidgetsBinding 相当于连接 engine 跟 widget layer 的桥梁,而 postFrameCallBack 是在每一帧渲染后,回调执行的。

那这个 addPostFrameCallback 回调是否靠谱,我们得通过源码分析看看。

源码分析

要点复习

既然涉及 .mm 跟 dart 的交互,我们先来看看 c++ 跟 dart 层是如何交互的:

C ++(Engine) 与 dart (Framework) 交互

主要集中在这几个文件里面:window.dart, window.cc,hooks.dart

dart 调用 c++

window.cc

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
//注册 native 方法
void Window::RegisterNatives(tonic::DartLibraryNatives* natives) {
natives->Register({
{"Window_defaultRouteName", DefaultRouteName, 1, true},
{"Window_scheduleFrame", ScheduleFrame, 1, true},
{"Window_sendPlatformMessage", _SendPlatformMessage, 4, true},
{"Window_respondToPlatformMessage", _RespondToPlatformMessage, 3, true},
{"Window_render", Render, 2, true},
{"Window_updateSemantics", UpdateSemantics, 2, true},
{"Window_setIsolateDebugName", SetIsolateDebugName, 2, true},
{"Window_reportUnhandledException", ReportUnhandledException, 2, true},
{"Window_setNeedsReportTimings", SetNeedsReportTimings, 2, true},
{"Window_getPersistentIsolateData", GetPersistentIsolateData, 1, true},
});
}

···

void Render(Dart_NativeArguments args) {
Dart_Handle exception = nullptr;
Scene* scene =
tonic::DartConverter<Scene*>::FromArguments(args, 1, exception);
if (exception) {
Dart_ThrowException(exception);
return;
}
UIDartState::Current()->window()->client()->Render(scene);//这个 clinet() 实际是 engine
}

window.dart

1
void render(Scene scene) native 'Window_render';//实际上是调用 window.cc 的native 方法

c++ 调用 dart

hooks.dart

1
2
3
4
5
@pragma('vm:entry-point')
// ignore: unused_element
void _drawFrame() {
_invoke(window.onDrawFrame, window._onDrawFrameZone);
}

window.dart

1
VoidCallback get onDrawFrame => _onDrawFrame;

image-20200321233237397

相关交互流程

整个渲染流程有点长,这里简要用白话总结下跟本文相关的几个交互步骤:

  1. Engine 层监听 Vsync 信号,通过 _drawFrame 告诉 framework 层,快准备好数据给我(Flutter::layer tree)

  2. Framework 层在 window.onBeginFrame , window.onDrawFrame 接收 Engine 的信息,把 widgets 的 UI 配置信息转化为 Layer, 最终产物是个 LayerTree ,通过 render() 发送个回 Engine

  3. Engine 在 GPU 线程 处理 LayerTree, 主要通过 rasterizer 做栅格化操作( 将 LayerTree 转化为 SkCanvas)

  4. 略····

    更详细的交互流程可以看这位大神的 blog: http://gityuan.com/2019/06/15/flutter_ui_draw/

我们写界面实际上是不用接触到 Layer 的, 是 Framework 层做了转换,看下图,Container 对应的 flutter::ContainerLayer,PlatformView 对应 flutter::PlatformViewLayer, 他们都继承于 Flutter::layer。

image-20200322154209908

https://engine.chinmaygarde.com/classflutter_1_1_layer.html

源码看到 PlatformViewLayer 这一层,其实离答案已经不远了。

调用流程

我们一起来看看 postFrameCallback 在被调用前究竟发生了什么?

image-20200322154209908

是先遍历执行了 _persistentCallbacks, 其中主角是下面的 drawFrame(),然后再遍历执行 postFrameCallback

1
2
3
4
5
6
7
8
9
10
11
///rendering/binding.dart

@protected
void drawFrame() {
assert(renderView != null);
pipelineOwner.flushLayout();
pipelineOwner.flushCompositingBits();
pipelineOwner.flushPaint();
renderView.compositeFrame(); // this sends the bits to the GPU
pipelineOwner.flushSemantics(); // this also sends the semantics to the OS.
}

其中由 renderView.compositeFrame();触发, 通过 _window.render 把相关信息 由dart 层 widget ui 数据回传给 engine C++ 层:

1
2
3
4
5
void compositeFrame() {
...
_window.render(scene); //这里调用 window.dart 的 render(Scene scene)
...
}
1
2
3
4
5
6
7
//Animator.cc
void Animator::Render(std::unique_ptr<flutter::LayerTree> layer_tree) {
...

//这个代理实际上是 shell.cc
delegate_.OnAnimatorDraw(layer_tree_pipeline_);
}

从这里开始,已经转到 GPU 线程了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
///shell.cc
// |Animator::Delegate|
void Shell::OnAnimatorDraw(fml::RefPtr<Pipeline<flutter::LayerTree>> pipeline) {
FML_DCHECK(is_setup_);

task_runners_.GetGPUTaskRunner()->PostTask(//切换线程
[& waiting_for_first_frame = waiting_for_first_frame_,
&waiting_for_first_frame_condition = waiting_for_first_frame_condition_,
rasterizer = rasterizer_->GetWeakPtr(),
pipeline = std::move(pipeline)]() {
if (rasterizer) {
rasterizer->Draw(pipeline);

if (waiting_for_first_frame.load()) {
waiting_for_first_frame.store(false);
waiting_for_first_frame_condition.notify_all();
}
}
});
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
///Rasterizer.cc
void Rasterizer::Draw(fml::RefPtr<Pipeline<flutter::LayerTree>> pipeline) {
TRACE_EVENT0("flutter", "GPURasterizer::Draw");
if (gpu_thread_merger_ && !gpu_thread_merger_->IsOnRasterizingThread()) {
// we yield and let this frame be serviced on the right thread.
return;
}
FML_DCHECK(task_runners_.GetGPUTaskRunner()->RunsTasksOnCurrentThread());

RasterStatus raster_status = RasterStatus::kFailed;
Pipeline<flutter::LayerTree>::Consumer consumer =
[&](std::unique_ptr<LayerTree> layer_tree) {
raster_status = DoDraw(std::move(layer_tree));
};
...
}
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
RasterStatus Rasterizer::DoDraw(
std::unique_ptr<flutter::LayerTree> layer_tree) {
FML_DCHECK(task_runners_.GetGPUTaskRunner()->RunsTasksOnCurrentThread());

if (!layer_tree || !surface_) {
return RasterStatus::kFailed;
}

FrameTiming timing;
timing.Set(FrameTiming::kBuildStart, layer_tree->build_start());
timing.Set(FrameTiming::kBuildFinish, layer_tree->build_finish());
timing.Set(FrameTiming::kRasterStart, fml::TimePoint::Now());

PersistentCache* persistent_cache = PersistentCache::GetCacheForProcess();
persistent_cache->ResetStoredNewShaders();

RasterStatus raster_status = DrawToSurface(*layer_tree);
if (raster_status == RasterStatus::kSuccess) {
last_layer_tree_ = std::move(layer_tree);
} else if (raster_status == RasterStatus::kResubmit) {
resubmitted_layer_tree_ = std::move(layer_tree);
return raster_status;
}

...
return raster_status;
}
1
2
3
4
5
6
7
RasterStatus Rasterizer::DrawToSurface(flutter::LayerTree& layer_tree) {
...
if (compositor_frame) {
RasterStatus raster_status = compositor_frame->Raster(layer_tree, false);
}
...
}
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
///compositor_context.cc
RasterStatus CompositorContext::ScopedFrame::Raster(
flutter::LayerTree& layer_tree,
bool ignore_raster_cache) {
TRACE_EVENT0("flutter", "CompositorContext::ScopedFrame::Raster");
bool root_needs_readback = layer_tree.Preroll(*this, ignore_raster_cache);
bool needs_save_layer = root_needs_readback && !surface_supports_readback();
PostPrerollResult post_preroll_result = PostPrerollResult::kSuccess;
if (view_embedder_ && gpu_thread_merger_) {
post_preroll_result = view_embedder_->PostPrerollAction(gpu_thread_merger_);
}

if (post_preroll_result == PostPrerollResult::kResubmitFrame) {
return RasterStatus::kResubmit;
}
// Clearing canvas after preroll reduces one render target switch when preroll
// paints some raster cache.
if (canvas()) {
if (needs_save_layer) {
FML_LOG(INFO) << "Using SaveLayer to protect non-readback surface";
SkRect bounds = SkRect::Make(layer_tree.frame_size());
SkPaint paint;
paint.setBlendMode(SkBlendMode::kSrc);
canvas()->saveLayer(&bounds, &paint);
}
canvas()->clear(SK_ColorTRANSPARENT);
}
layer_tree.Paint(*this, ignore_raster_cache); // 这句是重点
if (canvas() && needs_save_layer) {
canvas()->restore();
}
return RasterStatus::kSuccess;
}
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
///layer_tree.cc
void LayerTree::Paint(CompositorContext::ScopedFrame& frame,
bool ignore_raster_cache) const {
TRACE_EVENT0("flutter", "LayerTree::Paint");

if (!root_layer_) {
FML_LOG(ERROR) << "The scene did not specify any layers to paint.";
return;
}

SkISize canvas_size = frame.canvas()->getBaseLayerSize();
SkNWayCanvas internal_nodes_canvas(canvas_size.width(), canvas_size.height());
internal_nodes_canvas.addCanvas(frame.canvas());
if (frame.view_embedder() != nullptr) {
auto overlay_canvases = frame.view_embedder()->GetCurrentCanvases();
for (size_t i = 0; i < overlay_canvases.size(); i++) {
internal_nodes_canvas.addCanvas(overlay_canvases[i]);
}
}

Layer::PaintContext context = {
(SkCanvas*)&internal_nodes_canvas,
frame.canvas(),
frame.gr_context(),
frame.view_embedder(),
frame.context().raster_time(),
frame.context().ui_time(),
frame.context().texture_registry(),
ignore_raster_cache ? nullptr : &frame.context().raster_cache(),
checkerboard_offscreen_layers_,
frame_physical_depth_,
frame_device_pixel_ratio_};

if (root_layer_->needs_painting())
root_layer_->Paint(context);
}

其中 PlatformViewLayer 继承于 flutter::Layer , 我们聚焦在 PlatformViewLayer

1
2
3
4
5
6
7
8
9
10
///Platform_view_layer.cc
void PlatformViewLayer::Paint(PaintContext& context) const {
if (context.view_embedder == nullptr) {
FML_LOG(ERROR) << "Trying to embed a platform view but the PaintContext "
"does not support embedding";
return;
}
SkCanvas* canvas = context.view_embedder->CompositeEmbeddedView(view_id_);
context.leaf_nodes_canvas = canvas;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
///FlutterPlatformviews.mm
SkCanvas* FlutterPlatformViewsController::CompositeEmbeddedView(int view_id) {
// TODO(amirh): assert that this is running on the platform thread once we support the iOS
// embedded views thread configuration.

// Do nothing if the view doesn't need to be composited.
if (views_to_recomposite_.count(view_id) == 0) {
return picture_recorders_[view_id]->getRecordingCanvas();
}
CompositeWithParams(view_id, current_composition_params_[view_id]);//**
views_to_recomposite_.erase(view_id);
return picture_recorders_[view_id]->getRecordingCanvas();
}

然后终于回到这里 setFrame

1
2
3
4
5
6
7
8
9
void FlutterPlatformViewsController::CompositeWithParams(int view_id,
const EmbeddedViewParams& params) {
CGRect frame = CGRectMake(0, 0, params.sizePoints.width(), params.sizePoints.height());
UIView* touchInterceptor = touch_interceptors_[view_id].get();
touchInterceptor.layer.transform = CATransform3DIdentity;
touchInterceptor.frame = frame; // 就是这里了
touchInterceptor.alpha = 1;

}

分析

纵观整个调用流程,其中涉及到 由 UI 线程(dart )切换到 gpu 线程,其中最后的 setFrame 也是在 gpu 线程执行的,而 postFrameCallback 回调是在 dart 层,所以 postFrameCallback 跟 setFrame 并不是在同一个线程,即使按顺序执行下来,setFrame 也不一定比 postFrameCallback 回调执行前先发生。

那 addPostFrameCallback 是不是也就不能解决这个 frame 为 0 的问题了?

反转

我们都知道, dart 层只有一个线程,而在 engine 层,就不一样了:

当 IsIosEmbeddedViewsPreviewEnabled 为 true 时,

platform 跟 gpu 共用一个线程,且为主线程

i/o 操作独用一个线程

Ui 即 dart 层独用另外一个线程;

其他情况,platform、gpu、i/o, ui 各用一个线程

更多 engine 线程 相关的知识,可以参考这里:https://zhuanlan.zhihu.com/p/38026271

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
///FlutterEngine.mm
if (flutter::IsIosEmbeddedViewsPreviewEnabled()) {

flutter::TaskRunners task_runners(threadLabel.UTF8String, // label
fml::MessageLoop::GetCurrent().GetTaskRunner(), // platform
fml::MessageLoop::GetCurrent().GetTaskRunner(), // gpu
_threadHost.ui_thread->GetTaskRunner(), // ui
_threadHost.io_thread->GetTaskRunner() // io
);
// Create the shell. This is a blocking operation.
_shell = flutter::Shell::Create(std::move(task_runners), // task runners
std::move(windowData), // window data
std::move(settings), // settings
on_create_platform_view, // platform view creation
on_create_rasterizer // rasterzier creation
);
}

platform 跟 gpu 共用一个线程意味着啥?

意味着,在 addPostFrameCallback 内只要执行的 platform channel 的方法,都可以保证在 setFrame 后再执行

1
2
3
WidgetsBinding.instance.addPostFrameCallback((_) {
FlutterThunder.setRemoteVideoLayout(...);
});

结论

兜了一圈,最终的解决方案就是把 frame 相关的代码调用放在 addPostFrameCallback 里面

1
2
3
4
5
6
7
8
9
10
11
12

Widget _remoteMixinWidget() {
if (_remoteViewWidget == null) {
_remoteViewWidget = FlutterThunder.createNativeView((viewId) {
remoteViewId = viewId;
WidgetsBinding.instance.addPostFrameCallback((_) {
FlutterThunder.setRemoteVideoLayout(...);
});
}, key: _remoteKey);
}
return _remoteViewWidget;
}

最终流程如图所示:

未命名文件 (5)

参考:

https://juejin.im/post/5e6b5b11f265da57187c64bd

https://juejin.im/post/5c24acd5f265da6164141236

http://gityuan.com/2019/06/15/flutter_ui_draw/

https://zhuanlan.zhihu.com/p/38026271

I'm Terrence

Flutter 实战系列:个性化 ListView physics

发表于 2019-11-13

背景

由于这篇总结是产品需求驱动的,先简要描述下 Sofanovel 项目的需求:仿照 inkitt 首页,实现个带有 hover 效果的横向列表,我们先直接来看下最后实现效果:
![](http://emyms.bs2dl.yy.com/MmQzM2UyOTctYjZmMy00NTA5LWE2OTktMzViYmJjNzY1NzM0.gif)![](http://emyms.bs2dl.yy.com/MmQzM2UyOTctYjZmMy00NTA5LWE2OTktMzViYmJjNzY1NzM0.gif)

解决思路

这个需求在 iOS 原生的 UIKIt 下 很好解决的,UIScrollView 本来就有个 paging 的属性,来实现这个 “翻页” 效果。而 Flutter 也有个类似的控件 PageView, 我们先来看下 PageView 的实现:

PageView

普通的 PageView 实现是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
return Container(
height: 200,
width: 200,
child: PageView(
children: TestDatas.map((color) {
return Container(
width: 100,
height: 200,
color: color,
);
}).toList(),
),
)

效果是 width 永远不受控制,充满屏幕,如图:

另一种实现:
加上 PageController 的 viewportFraction 修饰:

1
2
3
4
5
6
7
8
9
10
11
12
13
return Container(
height: 200,
child: PageView(
controller: PageController(initialPage: 0, viewportFraction: 0.8),
children: TestDatas.map((color) {
return Container(
width: 100,
height: 200,
color: color,
);
}).toList(),
),
)

实现效果是这个样子的:

viewportFraction 这个参数只能粗略地表示 选中区域 占屏幕的百分比,而这个区域永远落在中央,不能简单实现偏左或者偏右的自定义化,因此舍弃了 pageView 的实现。

ListView

赋予翻页效果

从横向布局的 ListView 入手开搞,自定义一个带有 pageView 特性的 physics

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
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
class PagingScrollPhysics extends ScrollPhysics {
final double itemDimension; // ListView children item 固定宽度
final double leadingSpacing; // 选中 item 离左边缘留白
final double maxSize; // 最大可滑动区域

PagingScrollPhysics(
{this.maxSize,
this.leadingSpacing,
this.itemDimension,
ScrollPhysics parent})
: super(parent: parent);

@override
PagingScrollPhysics applyTo(ScrollPhysics ancestor) {
return PagingScrollPhysics(
maxSize: maxSize,
itemDimension: itemDimension,
leadingSpacing: leadingSpacing,
parent: buildParent(ancestor));
}

double _getPage(ScrollPosition position, double leading) {
return (position.pixels + leading) / itemDimension;
}

double _getPixels(double page, double leading) {
return (page * itemDimension) - leading;
}

double _getTargetPixels(
ScrollPosition position,
Tolerance tolerance,
double velocity,
double leading,
) {
double page = _getPage(position, leading);

if (position.pixels < 0) {
return 0;
}

if (position.pixels >= maxSize) {
return maxSize;
}

if (position.pixels > 0) {
if (velocity < -tolerance.velocity) {
page -= 0.5;
} else if (velocity > tolerance.velocity) {
page += 0.5;
}
return _getPixels(page.roundToDouble(), leading);
}
}

@override
Simulation createBallisticSimulation(
ScrollMetrics position, double velocity) {
// If we're out of range and not headed back in range, defer to the parent
// ballistics, which should put us back in range at a page boundary.

if ((velocity <= 0.0 && position.pixels <= position.minScrollExtent))
return super.createBallisticSimulation(position, velocity);

final Tolerance tolerance = this.tolerance;

final double target =
_getTargetPixels(position, tolerance, velocity, leadingSpacing);
if (target != position.pixels)
return ScrollSpringSimulation(spring, position.pixels, target, velocity,
tolerance: tolerance);
return null;
}

@override
bool get allowImplicitScrolling => false;
}

代码一大堆,我们聚焦入口 createBallisticSimulation ,这是每次滑动手势结束后会触发,最终都是为了调用下面这句,来产生滑动效果:

1
2
ScrollSpringSimulation(spring, position.pixels, target, velocity,
tolerance: tolerance);

target 这个参数是整个类的主角,其他辅助函数都是为了计算出这个值而已,target 是表示这次滑动的终点,也就是说,我们通过控制这个参数来控制这次触摸结束后,listview 停在哪里。

其次,构造方法里面里面的 parent 参数也是挺重要的,主要用来组合各种 physics 属性,这里留在后面再说。

选中动效

这一步无非就是用 scrollView 监听 scroll offset, 到了指定位置就 setState ,已触发选中效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
_scrollCtl.addListener(() {
double test =
_bookWidth != null ? _scrollCtl.offset / (_bookWidth + margin) : 1;
int next = test.round();
if (next < 0) {
next = 0;
}
if (next >= testData.length) {
next = testData.length - 1;
}
if (_currentPage != next) {
setState(() {
_currentPage = next;
});
}
});
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
_buildBookItem(Map data, bool active, {num width}) {
width = _bookWidth;
// Animated Properties
final double blur = active ? 5 : 0;
final double offset = active ? 2 : 0;
final double top = active ? 10 : 20;
final double bottom = active ? 10 : 20;

return GestureDetector(
onTap: () {
if (data['index'] == _currentPage) {
_jump();
} else {
scrollToPage(data['index']);
}
},
child: AnimatedContainer(
width: width,
height: 1.38 * width,
child: Center(child: Text(data['index'].toString())),
duration: Duration(milliseconds: 500),
curve: Curves.easeOutQuint,
margin: EdgeInsets.only(top: top, bottom: bottom, right: margin),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
color: randomColor,
boxShadow: [
BoxShadow(
color: Colors.black87,
blurRadius: blur,
offset: Offset(offset, offset))
]),
),
);
}

后话

在自测时发现过这样一个问题:当 listView 里面的 children 过少时, 整个 listView 压根不能滑动, physics 里面的 createBallisticSimulation 实现得再完美,也触发不了其中的回调的。为了避免这种情况,比较粗暴的方法是,在 children 加空白 Container,以充满 listView 固有的宽度或者高度,来让 listView 满足可滑动的前提。

正规军解法

为何 chidren 过少就滑动不了?这里要看下 ScrollPhysics 的源码了,里面有这样一个方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

/// Whether the scrollable should let the user adjust the scroll offset, for
/// example by dragging.
///
/// By default, the user can manipulate the scroll offset if, and only if,
/// there is actually content outside the viewport to reveal.
///
/// The given `position` is only valid during this method call. Do not keep a
/// reference to it to use later, as the values may update, may not update, or
/// may update to reflect an entirely unrelated scrollable.
bool shouldAcceptUserOffset(ScrollMetrics position) {
if (parent == null)
return position.pixels != 0.0 || position.minScrollExtent != position.maxScrollExtent;
return parent.shouldAcceptUserOffset(position);
}

源码里面注释得很清楚了,唯有内容超出显示范围时,才可以触发他的滚动,即 position.minScrollExtent != position.maxScrollExtent 的时候。
所以,我们重载一下这个方法就可以了。

1
2
@override
bool shouldAcceptUserOffset(ScrollMetrics position) => true;

另外,也可以通过构造方法 parent 这个入参去组合多个的已有的 physics 来完成这种特性:

1
2
3
4
5
_physics = PagingScrollPhysics(
itemDimension: itemWidth,
leadingSpacing: _leadingPortion,
maxSize: itemWidth * (testData.length - 1) - _leadingPortion,
parent: BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()));

I'm Terrence

Flutter实战系列: 实现顶级视图可拖动悬浮窗

发表于 2019-07-28

需求描述

这个需求有两个关键点:

  1. 顶级视图
  2. 可拖动

涉及 Widget 知识点

  1. Overlay,顶级视图解决方案
  2. Draggable,可拖动解决方案

Overlay

Overlay 之于 Flutter , 有点相当于 KeyWindow 之于 iOS 一样,可以将子 widget 置于其他 widget 的顶层,带来 “悬浮”的效果,具体可见注释:

1
2
3
4
5
6
7
8
/// A [Stack] of entries that can be managed independently.
///
/// Overlays let independent child widgets "float" visual elements on top of
/// other widgets by inserting them into the overlay's [Stack]. The overlay lets
/// each of these widgets manage their participation in the overlay using
/// [OverlayEntry] objects.
/// Rather than creating an overlay, consider using the overlay that is
/// created by the [WidgetsApp] or the [MaterialApp] for the application.

文档不建议我们重新初始化一个 overlay 对象 , 最好还是通过 Overlay.of(context),这样的方式去获取已经存在的 Overlay 对象。

这里就又引出了另外一个新概念 OverlayEntry

OverlayEntry

OverlayEntry 之于 Overlay,对于 iOS 开发而言,又有点 subView 之于 KeyWindow 的味道了。 OverlayEntry 是视图的实际的容器, 把其往 Overlay 那儿添加了,就可以成像了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/// Creates an overlay entry.
///
/// To insert the entry into an [Overlay], first find the overlay using
/// [Overlay.of] and then call [OverlayState.insert]. To remove the entry,
/// call [remove] on the overlay entry itself.
OverlayEntry({
@required this.builder, // builder 模式返回一个 widget
bool opaque = false, // 是否不透明
bool maintainState = false, // 这个属性与 opaque 有关系,如果某个 entry A的 opaque 被设成 true 了, 那么 overlay 就不去 build 其他在层级在 entry A 以下的 entry 了, 除非 maintainState 设成 true
}) : assert(builder != null),
assert(opaque != null),
assert(maintainState != null),
_opaque = opaque,
_maintainState = maintainState;

Draggable

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const Draggable({
Key key,
@required this.child, // 初始化显示的 widget
@required this.feedback, // 拖拽过程中(活动中)显示的 widget
this.data, // widget 携带的数据,放手时可以将这个 data 数据传递出去
this.axis, // 限制 draggable 的移动范围
this.childWhenDragging, // 拖住动作发生过程中,初始化位置显示的 widget
this.feedbackOffset = Offset.zero, // 当 feedback 与 child 相比,有 transform 的时候,需要用到这个属性来调整 hittest 范围
this.dragAnchor = DragAnchor.child, //锚点
this.affinity, // 单词的意思是亲和力,当 Draggable 位于 另外一个 Scrollable 控件內时,来控制到底这个这个拖拽事件到底由 Draggable 响应,还是由 Scrollable 控件来响应
this.maxSimultaneousDrags, // 限制有多少个 Draggable 同时发生 拖拽动作
this.onDragStarted, // 拖拽动作开始回调
this.onDraggableCanceled, // 拖拽动作取消回调
this.onDragEnd, //拖拽动作结束回调
this.onDragCompleted, // 拖拽动作完成回调, 并被一个 DragTarget 接收
this.ignoringFeedbackSemantics = true, // 也是看了文档才知道,这个属性还是有点用的,当 feedback 跟 child 是同一个 widget A 对象时,就应该把这个属性设成 false, 配合赋值一个 GlobalKey,这样,这个 widget A 就不会在 feedback 跟 child 切换时,重新销毁后又创建了。这个在 widget A 带有播放动画是比较容易看出区别,每次手指拖放都伴随着动画的重新开始
})

一开始只留意到 feedback, childWhenDragging, onDragEnd 几个参数,实际上 ignoringFeedbackSemantics 也是挺重要的,这个放在后面再说。

把我们想要实现拖拽功能的 widget 传到 child 参数位置的时候,跑一下,可以发现,我们已经实现了拖拽功能了,但这个时候,当我们手指离开屏幕的话,child 又自动回到了初始化的位置了,并没有停留在我们想要他停留的位置,为了实现这个功能,我们又得用到另外一个 widget : DragTarget

DragTarget

1
2
3
4
5
6
7
const DragTarget({
Key key,
@required this.builder, //根据 Draggable 传过来的 data ,来显示想要的 widget
this.onWillAccept, // 根据传过来的 data ,选择是否接收这个 Draggable, 返回 true 则激活 onAccept
this.onAccept, // Draggable 被丢进了这个 DragTarget 区域后回调
this.onLeave, // Draggable 离开 DragTarget 区域后的回调
}) : super(key: key);

DragTarget 是用来作为 Draggable 被拖拽结束后接收他的区域, 当然 他可以通过 onWillAccept 的 data ,来选择 接不接收这个 Draggable 。

好了,前面搬文档说了一大堆废话,下面,我们来将这个几个 widget 组合运用起来,实现文章一开始的需求。

组合起来

关键代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
static void show({@required BuildContext context, @required Widget view}) {
TestOverLay.view = view;

//避免重复 show
remove();

//创建一个OverlayEntry对象
OverlayEntry overlayEntry = new OverlayEntry(builder: (context) {
//通过 Positioned 控制 位置
return new Positioned(
top: MediaQuery.of(context).size.height * 0.7,
child: _buildDraggable(context));
});

//往当前 Overlay 中插入 OverlayEntry
Overlay.of(context).insert(overlayEntry);

_holder = overlayEntry;
}

show 方法无非做了 2 件事:

  1. _buildDraggable
  2. 创建 OverlayEntry, 并插入到当前上下文的 Overlay

再看下 _buildDraggable

1
2
3
4
5
6
7
8
9
10
11
12
13
14
static _buildDraggable(context) {
return new Draggable(
child: view, // child 跟 feedback 用传入同一个 view,这样初始化跟拖拽过程都显示这个 view
feedback: view, //
onDragStarted: () {
print('onDragStarted:');
},
onDragEnd: (detail) {
print('onDragEnd:${detail.offset}');
createDragTarget(offset: detail.offset, context: context); // 放手的时候创建一个DragTarget
},
childWhenDragging: Container(), // 这里传个 Container,原来位置啥都不显示
);
}

放手的时候创建一个 DragTarget对象,用来接收 Draggable

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
static void createDragTarget({Offset offset, BuildContext context}) {
if (_holder != null) {
_holder.remove();
}

_holder = new OverlayEntry(builder: (context) {
bool isLeft = true;
if (offset.dx + 100 > MediaQuery.of(context).size.width / 2) {
isLeft = false;
}

double maxY = MediaQuery.of(context).size.height - 100;

return new Positioned(
top: offset.dy < 50 ? 50 : offset.dy < maxY ? offset.dy : maxY,
left: isLeft ? 0 : null,
right: isLeft ? null : 0,
child: DragTarget(
onWillAccept: (data) {
print('onWillAccept: $data');
return true;
},
onAccept: (data) {
holded = true;
print('onAccept: $data');
// refresh();
},
onLeave: (data) {
print('onLeave');
},
builder: (BuildContext context, List incoming, List rejected) {
return _buildDraggable(context);
},
));
});
Overlay.of(context).insert(_holder);
}

这里也是通过 Positioned 来给 DragTarget 指定位置的,需求对 Draggable 携带的 data 不关心,来者不拒,所以 onWillAccept 那儿直接 return true了;

当接收了 Draggable 后,在 builder 返回想要显示的内容,这里,我们直接返回之前那个 Draggable 对象好了,为下次的拖拽做好准备。

到此为止,整个流程就结束了。

这里看下初步实现效果:

优化

细心的同学可以很容易会发现,每次拖拽动作的开始,结束的时候,view 的旋转动画都会被重置,体验并不友好。看了下日志就知道,在这两个时刻, 都会触发 view 的重建和销毁:

ignoringFeedbackSemantics

文档提示我们,当 Draggable 的 child跟feedback相同时, ignoringFeedbackSemantics = false ,与 GlobalKey 配合使用,可以让 feedback 在 child 切换时,所对应 widget 不被 销毁 和 重新创建,这样设置后,再看下日志

onDragStated,onDragEnd,虽然也触发了 MiniRoomFloatingWidget 的 build 方法,但并没有销毁及重创建。

在来看下优化后的效果:

最后附上代码

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
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
import 'package:flutter/material.dart';

class TestOverLay {
static OverlayEntry _holder;

static Widget view;

static void remove() {
if (_holder != null) {
_holder.remove();
_holder = null;
}
}

static void show({@required BuildContext context, @required Widget view}) {
TestOverLay.view = view;

remove();
//创建一个OverlayEntry对象
OverlayEntry overlayEntry = new OverlayEntry(builder: (context) {
return new Positioned(
top: MediaQuery.of(context).size.height * 0.7,
child: _buildDraggable(context));
});

//往Overlay中插入插入OverlayEntry
Overlay.of(context).insert(overlayEntry);

_holder = overlayEntry;
}

static _buildDraggable(context) {
return new Draggable(
child: view,
feedback: view,
onDragStarted: (){
print('onDragStarted:');
},
onDragEnd: (detail) {
print('onDragEnd:${detail.offset}');
createDragTarget(offset: detail.offset, context: context);
},
childWhenDragging: Container(),
);
}

static void refresh() {
_holder.markNeedsBuild();
}

static void createDragTarget({Offset offset, BuildContext context}) {
if (_holder != null) {
_holder.remove();
}

_holder = new OverlayEntry(builder: (context) {
bool isLeft = true;
if (offset.dx + 100 > MediaQuery.of(context).size.width / 2) {
isLeft = false;
}

double maxY = MediaQuery.of(context).size.height - 100;

return new Positioned(
top: offset.dy < 50 ? 50 : offset.dy < maxY ? offset.dy : maxY,
left: isLeft ? 0 : null,
right: isLeft ? null : 0,
child: DragTarget(
onWillAccept: (data) {
print('onWillAccept: $data');
return true;
},
onAccept: (data) {
print('onAccept: $data');
// refresh();
},
onLeave: (data) {
print('onLeave');
},
builder: (BuildContext context, List incoming, List rejected) {
return _buildDraggable(context);
},
));
});
Overlay.of(context).insert(_holder);
}
}

参考

https://medium.com/flutter-community/a-deep-dive-into-draggable-and-dragtarget-in-flutter-487919f6f1e4

I'm Terrence

Flutter: tabBar一定要居中吗?

发表于 2019-06-24

提问

flutter 的 tabbar 都是这个样子居中的,

能不能不居中呢?
一开始对了下那 tabbar api 中的几个属性,

const TabBar({
Key key,
@required this.tabs,
this.controller,
this.isScrollable = false,
this.indicatorColor,
this.indicatorWeight = 2.0,
this.indicatorPadding = EdgeInsets.zero,
this.indicator,
this.indicatorSize,
this.labelColor,
this.labelStyle,
this.labelPadding,
this.unselectedLabelColor,
this.unselectedLabelStyle,
this.dragStartBehavior = DragStartBehavior.start,
this.onTap,}
) 

发现通过设置属性是不能达到预想效果,于是,弱弱地走去了人家官网提了个 issue, can tabBar not centered ? 过了几天,flutter 工程师还加了个这样标签 f: material design framework ,相当于承认了这个问题?哈哈

自问自答

isScrollable 这个属性默认是 false 的,把他设成 isScrollable = true 后,发现到有个细节改变了,由原来充满这一行的的表现,变成“收敛”了。

既然每个 tab 都收敛了,那么现在只剩下一个问题了:如何把整个 tabBar 向左对齐?

我第一个想到的是,把 tabBar 用个 container 包裹着,设置下 alignment 不就行了?

Container(
      alignment: Alignment.topLeft,
      child: TabBar(
        labelColor: Colors.black,
        isScrollable: true,
        labelStyle: TextStyle(fontSize: 15, fontWeight: FontWeight.bold),
        unselectedLabelColor: Colors.grey,
        unselectedLabelStyle: TextStyle(fontSize: 11),
        controller: _tabController,
        tabs: tabs.map((e) => Tab(text: e)).toList(),
      ),
    )

是的,指定alignment: Alignment.topLeft这样就可以实现这种效果了

延伸

如果需要放在 Scaffold.appBar 里面的话,则需要额外实现 PreferredSizeWidget 这个接口,PreferredSize 这个 widget 就实现了这个接口,我们用 PreferredSize 再包一层,即可。

这里注意一下,preferredSize 这个属性, 并不是用来约束他的 child, 而是当这个组件本身没有设置任何约束时,对自己大小的一种声明。也就是说,如果外部对其是有约束的话,这个属性是用不着的。

/// The size this widget would prefer if it were otherwise unconstrained.
/// A widget with a preferred size.
///
/// This widget does not impose any constraints on its child, and it doesn’t
/// affect the child’s layout in any way. It just advertises a preferred size
/// which can be used by the parent.

I'm Terrence

C++

发表于 2018-05-20

C ++^1

尽量不要使用指针和数组,因为太过底层,很可能出现不可预测的问题。尽量使用vector等对其的封装的高级用法。

引用 vs 指针

引用 相当于变量的别名

int ivar = 1;
int &i = ivar;
int *pi = &ivar;

cout << "i = "<< i << endl; // 相当于i 为 ivar的别名
cout << "pi = "<< pi << endl;
cout << "*pi = "<< *pi << endl;
cout << "&i = "<< &i << endl;
cout << "&ivar = "<< &ivar << endl;

i = 1
pi = 0x7fff5fbff73c
*pi = 1
&i = 0x7fff5fbff73c

&ivar = 0x7fff5fbff73c

表明指针就是存放对象地址的地方。

constant

const int *p = &ivar;
int a = 4;
p = &a;

​
​ int const conPtr = &ivar;
​
conPtr = 4;

右边的总和不能变

const int * const bothConptr = &ivar;
I'm Terrence

iOS项目部分代码独立子工程

发表于 2017-06-12

项目在迭代到一定程度的时候,自然而然地,就有需求去将某部分功能的代码独立出来,这也是个必然经过的重构阶段。
网上关于这部分的资料其实已经很全了,在这里我就总结一下这方面重构的感悟吧。

mov files

这次commit log最多字眼字眼就是mov files了。一移动文件,svn肯定就有增有减的标记。一开始挺怕删的文件比加进去的多的,所以每次都数一下两者文件数量是否一致。
在这个动作之前,肯定肯定会对要操作的文件进行showInFinder,打开了,发现这真是个有趣的地方。
不同习惯的开发者,创建新文件的风格是不同
习惯不好的,对xcode的文件结构即使已经分了group了,但show in finder进去一团糟。
在这里总结出第一条:
new file的时候,finder文件结构尽量和xcode目录结构一样,一个文件夹对应一个group

还有有个小技巧:
每次改变finder文件结构后,最好都clean一下项目,然后再build,不然有大概率报错找不到头文件。

在子工程里面

Resource

这里应该是最麻烦的地方了。原来在主工程用到mainBundle的地方,通通要改成对应的子bundle。

bundle

创建子工程bundle具体操作如下图
上图



在主项目里面

Build Settings -> user header Search paths

Build Phases -> link binary with libaries

要在这里添加.a文件进去。

Build Phases -> Copy Bundle Resources

把子工程的资源文件.bundle添加进去。

Build Phases -> Target Dependencies

配置依赖,把在子工程添加进去。这样每次编译的时候,就会先把里面的子工程编译过了,再去搞主工程。没弄这个的话,每次一动子工程什么地方了,必须特定对那个子工程进行build···

子工程加载图片 imageName:?

从上面步骤下来,子工程用代码加载子bundle图片是加载不出来的,特别是iOS7,连xib 都load不出图片来。7以上是可以的。
因为直接用imageName:是有问题的,load 出来是nil来的。
看api才知道,imageName:是load from main bundle的,对于子bundle,代码要做如下处理

+ (NSString *)resourceName:(NSString *)name withBundleName:(NSString *)bundleName
{
    return [NSString stringWithFormat:@"%@.bundle/%@", bundleName, name];
}

+ (UIImage *)imageNamed:(NSString *)imageName withBundleName:(NSString *)bundleName
{
    NSBundle *bundle = [self bundleNamed:bundleName];
    if (SystemLessThan(8.0)) {
        NSString *path = [[bundle resourcePath] stringByAppendingPathComponent:imageName];
        UIImage *image = [UIImage imageWithContentsOfFile:path];
        return image;
    }
    else {
        return [UIImage imageNamed:imageName inBundle:bundle compatibleWithTraitCollection:nil];
    }
}

+ (NSArray *)loadNibNamed:(NSString *)name owner:(id)owner withBundleName:(NSString *)bundleName
{
    NSBundle *bundle = [self bundleNamed:bundleName];
    return [bundle loadNibNamed:name owner:owner options:nil];
}

+ (NSBundle *)bundleNamed:(NSString *)bundleName
{
    NSString *bundlePath = [[NSBundle mainBundle] pathForResource:bundleName ofType:@"bundle"];
    return [NSBundle bundleWithPath:bundlePath];
}

这里需要特别注意,这个方法无法直接加载image assets里@2x图片,必须逐一把图片拉出来,然后加进bundle里面,才可以成功读取···

12
Terrence

Terrence

上帝只救自救者

17 日志
8 标签
© 2021 Terrence
由 Hexo 强力驱动
主题 - NexT.Muse