Powerline fonts & Nerd fonts 简介

作为程序员,和命令行打交道很频繁,设置一个赏心悦目的命行行 prompt 或者 Vim 的 status line 主题就很有必要了,不过一般这些漂亮的主题都会用到一些 icon 字符,这些 icon 字符一般的字体里是没有的,今天我们就来聊聊一些带有 icon 字符的字体。

为了有个直观的认识,我们来看一下设置命令行 prompt 特殊主题前后的对比: prompt_theme

设置 Vim statusline 主题前后对比: vim-statusline_theme

Powerline fonts

Powerline 是一款 Vim statusline 的插件,它用到了很多特殊的 icon 字符。Powerline fonts 是一个字体集,本质是对一些现有的字体打 patch,把 powerline icon 字符添加到这些现有的字体里去,目前对 30 款编程字体打了 patch.

Oh My Zsh! 是一个很受欢迎的 zsh 配置管理工具,它的一些主题需要用到一些特殊的的字体,比如 agnoster,需要用到 Powerline fonts

下图是 agnoster 主题的预览效果,使用的是 Source Code Pro for Powerline 字体。 -w462

如果使用的是没有打 Powerline-patch 的字体,可以看到很多特殊字符都会显示不正确,这也是很多爱好者安装一些主题后,显示效果不理想的原因。

-w576

Powerline fonts 默认对很多编程字体打了 patch(默认支持的所有字体参考 这里 ,比如我个人比较喜欢的 Source Code Pro 字体,Powerline fonts 对打过 patch 的字体做了重命名,后面都加上了 for Powerline 的后缀,比如 Source Code Pro 打完 patch 后名字改为了 Source Code Pro for Powerline

到这里,我们可以这么理解 Powerline font,以 Source Code Pro for Powerline 为例:

1
Source Code Pro for Powerline 字符集 = `Source Code Pro` 基础字符 + Powerline Icons 字符集

ps: Font Book 貌似看不到 Icon 字符,可以使用 在线工具 查看甚至编辑字体里所有的字符。

Powerline fonts 下载和安装

下载和安装很简单,

1
2
3
4
5
# clone
git clone https://github.com/powerline/fonts.git --depth=1
# install
cd fonts
./install.sh

下载完之后,如果用的是 macOS, 就可以在 Font Book 里来查看已安装的字体了。

如果你在使用 iTerm2 等,安装完之后,需要在 "iTerm > Preferences > Profiles > Text" 里设置字体为你喜欢的 Powerline fonts,才能看到效果,macOS 自带的 Terminal.app 也同理。

Nerd font

Nerd font 的原理和 Powerline fonts 是一样的,也是针对已有的字体打 patch,把一些 icon 字符插入进去。不过 Nerd font 就比较厉害了,是一个“集大成者”,他几乎把目前市面上主流的 icon 字符全打进去了,包括上面刚刚提到的 powerline icon 字符以及 Font Awesome 等几千个 icon 字符。

我比较喜欢的一款 Oh My Zsh! 主题 powerlevel10k/powerlevel10k,就推荐使用 Nerd font(虽然也支持 powerline font),显示效果如下:

-w892

Nerd font 的安装

Nerd font 对 50 多款编程字体打了 patch(具体请参考 这里,和 Powerline fonts 类似,也会在 patch 后,对名字做一下修改,比如 Source Code Font 会修改为 Sauce Code Nerd Font (Sauce Code 并非 typo,故意为之)

安装方式很多,我推荐通过 brew 来安装,比如我要安装 Sauce Code Nerd Font 的话,可以下方的命令。

1
2
brew tap homebrew/cask-fonts
brew cask install font-sourcecodepro-nerd-font-mono -v

安装完之后,也需要修改对应客户端的字体(比如 iTerm/Terminal.app)后,各种主题的效果才会生效。

总结

  1. Powerline fonts 或者 Nerd fonts 这些字体集,他们对已有的一些 (编程) 字体打了 patch,新增一些 icon 字符。
  2. Nerd fonts 是 Powerline fonts 的超集,建议直接使用 Nerd font 就好了

Have fun!

Linux/Unix 文件的 atime ctime mtime 属性

一、什么是 atime、ctime、mtime?

Linux/Unix 系统会为每个文件保存一些时间戳信息,我们可以根据这些时间戳来判断文件什么时候被读取过,什么时候被修改过内容,以及什么时候被修改过文件的权限。以下是 atime,ctime,mtime 三个时间戳的介绍。

  1. atime (last Access time)

    最近一次访问文件的时间,访问指的是 读取或者执行文件 / 文件夹的时间

  2. ctime (last Change time)

    最近一次 metadata 修改的时间,这里修改有两层意思:

    1. 修改文件 / 文件夹的 metadata,比如 user/group 或者访问权限(比如 chmod)
    2. 修改文件内容
  3. mtime (last Modify time)

    最近一次修改的时间,这里的修改 专指文件的内容修改

注意:

  1. 当创建文件时候,atime、ctime、mtime 都会修改为当前创建的时间
  2. 当修改文件内容的时候,ctime、mtime 都会更新为修改时间

二、如何查看文件的 atime、ctime、mtime

查看单个文件可以使用 stat 来查看:

1
2
3
4
5
6
7
8
9
> stat ./output.log                            
File: ./output.log
Size: 8 Blocks: 8 IO Block: 4096 regular file
Device: fc01h/64513d Inode: 1851173 Links: 1
Access: (0766/-rwxrw-rw-) Uid: (0/ root) Gid: (0/ root)
Access: 2020-01-03 22:59:18.272535807 +0800
Modify: 2020-01-03 23:00:04.506391646 +0800
Change: 2020-01-04 00:34:59.238825835 +0800
Birth: -

ps: macOS 默认单行显示结果,如果希望多行直观地显示,需要加上 -x 参数

常用的 ls -l 默认的是输出 mtime,如果想输出 atime 需要使用 ls -lu,ctime 需要使用 ls -lc ,可以看到这三种时间戳和上面是一致的。

1
2
3
4
5
6
> ls -l  ./output.log.log                          
-rw-rw-rw- 1 root root 8 Jan 3 23:00 ./output.log
> ls -lu ./output.log.log
-rw-rw-rw- 1 root root 8 Jan 3 22:59 ./output.log
> ls -lc ./output.log.log
-rw-rw-rw- 1 root root 8 Jan 4 00:34 ./output.log

三、可以拿这三个时间戳做什么?

我们可以通过 find 命令使用这几个属性来做过滤,比如查看 3 天以内被修改过的文件,或者 6 月之内没有修改过的旧文件。

怎么使用呢?我们拿 mtime 也就是文件修改时间来说的话,可以通过 find ./ -mtime n 进行过滤和查询,这里的 n 用来描述时间,分三种情况(以下的“天也可以理解为 24 小时):

  • n 没有正负符号,或者说 n 天前的 当天 ,或者 n * 24 小时之前(的 24 个小时)。比如 3 表示 3 天前当天,或者 72 之前的 24 小时内修改过的文件。
  • +n 表示 n 天前那一整天 之前的所有时间,或者说(n+1) * 24 小时之前的时间。因为 n 本身表示 n 天前的那一整天,+n 表示这一整天之前,其实就是 n+1 天前
  • -n 表示 n 天前那一整天 之后的时间,或者 n24 以内的时间,也就是 [未来,n24 前 ],-2 表示小于 2 天内以及未来修改过的文件,或者说 48 小时内加上未来的时间段。(未来的时间不可能发生,所以也可以忽略)

如果还是有点绕,我们来看一些图例:

find ./ -mtime 2 表示 2 天前的当天,find ./ -mtime 0 表示 0 天前的当天,也就是今天,或者说 24 小时以内:

1
2
3
4
5
6
|▶mtime 0◀|         |▶mtime 2◀| 
┌─────────┬─────────┬─────────┬─────────┐
│ 24hrs │ 24hrs │ 24hrs │ 24hrs │
└─────────┴─────────┴─────────┴─────────┘
now 1 days 2 days 3 days
ago ago ago

OK,我们知道两天前的当天用 -mtime 2 表示, 那 -mtime +2 表示比 -mtime 2 更早的时间,也就是从 3 天前以前的时间,而 -mtime -2 表示比 -mtime 2 更晚的时间,也就是 48 小时内的时间。

1
2
3
4
5
6
 ◀─── -mtime -2 ────|▶mtime 2◀|────── ─mtime +2 ────────────▶
┌─────────┬─────────┬─────────┬─────────┬─────────┬─────────┐
│ 24hrs │ 24hrs │ 24hrs │ 24hrs │ 24hrs │ 24hrs │
└─────────┴─────────┴─────────┴─────────┴─────────┴─────────┘
now 1 days 2 days 3 days 4 days
ago ago ago ago

到这里其实我们也可以得出 -mtime 2 等价于 -mtime 1 -mtime -3

-mtime 0-mtime -1 是否等价呢?一个是今天,一个是 一天前 之后的时间,所以理论上是不等价的,因为 -mtime -1 除了今天还包含未来的所有时间,但大部分情况下都是可以通用的,因为未来的时间还没发生,过滤文件应该没有问题(除非把系统时间调整到过去)。

我们也可以使用 find ./ -mtime +1 -mtime -5 来查找 2 天前,5 天内被修改的文件

1
2
3
4
5
6
                    |▶──── -mtime +1 -mtime -5──◀|          
┌─────────┬─────────┬─────────┬─────────┬─────────┬─────────┐
│ 24hrs │ 24hrs │ 24hrs │ 24hrs │ 24hrs │ 24hrs │
└─────────┴─────────┴─────────┴─────────┴─────────┴─────────┘
now 1 days 2 days 3 days 4 days 5 days
ago ago ago ago ago

最开始我通过 man find 来查看 -mtime n 的详细使用用法,不过看完依然一头雾水。这里 ago 的意思,不是指“之前”所有的时间,而是“之前”的那一天或者是“之前”的 24 小时。

1
2
3
4
5
-mtime n
File's data was last modified n*24 hours **ago**. See the comments for -atime to understand how rounding affects the interpretation of file modification times.

-atime n
File was last accessed n*24 hours ago. When find figures out how many 24-hour periods ago the file was last accessed, any fractional part is ignored, so to match -atime +1, a file has to have been accessed **at least** two days ago.

参考:

atime, ctime and mtime in Unix filesystems

Selecting files using their age

结合 AppRTC 源码分析 WebRTC 建立连接的过程

这两年来,WebRTC 越来越多地出现在人们的视野,在在线教育,在线医疗等领域的应用也越来越多。大家研究 WebRTC 的热情也越来越高涨,不过 WebRTC 的入门门槛个人觉得稍微有些高,特别是各种概念,比如 NAT 穿越,ICE,STUN,TURN,Signaling server 等等,刚开始可能会觉得比较繁杂,不易理解。然后建立连接的整个过程,异步调用比较多,很容易搞混。那么这篇文章里我们会根据 WebRTC 的官方 demo AppRTC 的 iOS 版本来分析一下 WebRTC 从进入房间到建立音视频连接的过程,为了便于了解,我们本次的讨论不涉及到底层的具体实现。

1. 相关概念

我们首先来简单地了解几个概念:

1.1 NAT 穿越(NAT Traversal)

因为 WebRTC 是 P2P 的,很多时候 peer 是隐藏在 NAT 之后的,没有外网的 IP 地址,如果两个 peer 都在 NAT 后面,都没有外网的 IP (或者说都不知道自己的外网 IP),是无法建立连接的。那么 NAT 穿越就是用来解决这个问题的,NAT 穿越也俗称 “P2P 打洞”。常见的两种穿越方式是 STUN 和 TURN。

1.2 STUN server

最新的 STUN 定义是 Session Traversal Utilities for NAT,可以参考 RFC5389 (https://tools.ietf.org/html/rfc5389),顾名思义,他是一个 NAT 穿越的工具,既然上面我们知道了 NAT 之后的 peer 可能不知道自己的外网 IP 是多少,那么 STUN 这个工具就可以帮助内网的主机拿到,并告诉他外网对应的 IP 地址。

1.3 TURN server

TURN 是 Traversal Using Relays around NAT 的缩写,可以参考 RFC5766 (https://tools.ietf.org/html/rfc5766)。有些内网类型比较复杂,比如对称型的 NAT,STUN server 拿到的外网对应的 IP 之后,还是无法通信,这时候就需要一个服务器来做数据的中转 (也叫中继,或者 relay),这个中转服务器就叫做 TURN server。

根据统计数据表明,STUN 可以解决 85% 左右情况下的 NAT 问题,剩余的就需要 TURN 来解决。这两种穿越方式对比来看,STUN 更简单,服务器的消耗和成本比较低,但是能解决问题的场景受限制,TURN 服务器可以解决几乎所有场景下的问题(包含 STUN 可以解决的场景),但是因为需要做数据中转,所以对服务器的性能要求比较高,成本也会比较高。一般情况下会两者兼用,首先尝试 STUN,STUN 解决不了的 case 用 TURN。

1.4 ICE

ICE 是 Interactive Connectivity Establishment 的缩写,可以参考最新的 RFC8445 (https://tools.ietf.org/html/rfc8445) 规范。顾名思义,ICE 就是 交互式连接建立 的意思,ICE 描述了一种使用 STUN 和 TURN 来穿越 NAT 建立 P2P 连接的一种规范。

每个 peer 可以收集到 3 种服务器地址,一个是自己网卡上绑定的 IP 地址,也叫 Local Address, 第二个是 STUN server 告诉自己的外网的地址,比如路由器上绑定的外网 IP 地址,叫做 Server Reflexive Address,第三个是 TURN server 给自己创建的中转服务器 IP 地址,叫做 Relayed Address

在 ICE 标准里,每个 peer 收集所有上述三种种类的 IP 地址和端口,并发送到对方 peer(体现了 *“交互”*),对方也收集所有三种类型的 IP 地址和端口,然后发送给自己。这样,自己和对方都有了彼此的所有 IP 地址和端口之后,开始按照优先级建立连通性检查,一旦找到一个可以互通的连接,就开始用该连接进行音视频数据传输。

1.5 ICE candidate

ICE 候选人,可以简单理解为就是上面所说的每个 peer 收集到的 IP 地址和端口(实际要比这个复杂,包含传输方式等等)。收集的过程,叫做 ICE candidate gathering.

1.6 SDP

然后我们说一下 SDP,SDP 是 Session Description Protocol 的缩写,可以参考 RFC4566 (https://tools.ietf.org/html/rfc4566) 规范。在介绍这个之前,我们来思考一个问题,如果我们要用 WebRTC 来进行 P2P 的视频通话,可能两端所支持的音视频格式集合不完全一致,比如一端支持 H264 和 VP8,另一端支持 VP8 和 VP9,那如何选择呢?SDP 就是来描述每个 peer 所支持的音视频格式,以及如何决定传输的音视频格式的。

1.7 Signaling Server

上面提到的 ICE candidate 和 SDP 都需要传给对方,因为没办法直接传给对方,所以一般通过服务器来中转,这个中转的过程,并不在 WebRTC 规范里,所以使用者可以自己来实现。一般来说,可以使用 WebSocket 服务器来实现。比如建立连接的 A、B 双方都连接到同样一个 WebSocket 服务器,A 发到服务器的 ICE candidate 或者 SDP,服务器都直接转发给 B,同理也会把 B 的消息转发给 A,达到交换的目的。

2. 整体流程图

假设有两个人,我们姑且称作为 Alice 和 Bob,通过 AppRTC 这个 demo app,进入了同一个房间。下面这个序列图就是客户端上的整体流程,为了简化理解,这里只设定了 5 个参与者,首先是两端(Alice 和 Bob),然后是 Signaling server & webserver,最后是两个端的底层 SDK 或者所在的浏览器(浏览器场景和客户端很类似,所以放一起了)。

我们逐步的来说:

  • 1-2, 3-4:Alice 和 Bob 都通过 API 进入房间,joinRoom 获得 roomId,并且通过 requestIceServers 获得 Ice server (也就是 STUN 和 TURN server)的地址,然后连接到 WebSocket 服务器上。
  • 5-6,7-8:Alice 和 Bob 都创建一个 PeerConnection,这个是个很重要的类,PeerConnection 负责编解码和传输的所有处理。
  • 9-10:createOffer,创建 offer,通过创建 offer,底层会返回 SDP,就是自己所支持的音视频格式等描述信息。
  • 10-12:createOffer 之后,把得到的 SDP,设置为 local description,同时会触发底层的 ICE candidate gathering,此时开始收集自己的 ICE candidate,也就前面提到的三种 IP 地址和端口。
  • 13-14:把 SDP 通过 signaling server 发送给 Bob
  • 15,16-17:Bob 收到 SDP 之后,设置为 remote description,之后 createAnswer,createAnswer 和 createOffer 很类似,也会收到底层返回的自己的 SDP。并设置为 local description。设置 local description 会触发底层 ICE candidate gathering. 此时,Bob 有了自己和对方的 SDP.
  • 20-21:Bob 把自己的 SDP 通过 signaling server 发给 Alice
  • 22: Alice 把收到的 Bob 的 SDP,并设置为 remote description,此时,Alice 也有了自己和对方的 SDP 了。
  • 23-26:因为在 step 12 的时候开始了 ICE candidate gathering,当每收集到一个 candidate 之后,会触发 didGenerateIceCandidate 的回调, 此时 Alice 不做处理,直接通过 Signaling server 传递给 Bob,Bob 把 candidate 添加到 peer connection 里。
  • 27-30: 同 23-26 ,Bob 把自己的 IP 地址告诉 Alice。

此后,Alice 和 Bob 的底层就开始进行连通性测试和协商,一旦找到一对互通的传输地址,就开始传输音视频数据,彼此就能看到对方了。

实际中的流程比这个要复杂,像 candidate gathering 是异步的,可能穿插在整个过程里面。所以有些状态需要判断。

3. 结合源码分析

OK 既然大致的流程我们看完了,我们来看看具体的代码实现。AppRTC 源码可以在官网 (https://webrtc.org/native-code/ios/) 上找到,这里不详细地介绍如何下载源码了

PS: AppRTC 和 Web 端 Demo (https://appr.tc) 是互通的。STUN 和 TURN server 默认用的都是 Google 提供的。

主要的逻辑集中在 ARDAppClient 这个类里面。当进入 app,输入房间号,点 Call Room 之后就执行到了 connectToRoomWithId:settings:isLoopback: 方法里。

下图是 AppRTC 的主界面

3.1 创建 RTCPeerConnectionFactory

ARDAppClient: connectToRoomWithId:settings:isLoopback:

1
2
3
4
5
// 创建 peerConnectionFactory,以及对应的 video codec factory
RTCDefaultVideoDecoderFactory *decoderFactory = [[RTCDefaultVideoDecoderFactory alloc] init];
RTCDefaultVideoEncoderFactory *encoderFactory = [[RTCDefaultVideoEncoderFactory alloc] init];
encoderFactory.preferredCodec = [settings currentVideoCodecSettingFromStore];
_factory = [[RTCPeerConnectionFactory alloc] initWithEncoderFactory:encoderFactory decoderFactory:decoderFactory];

在这里,首先初始化视频编解码器的工厂类,并通过他们创建出一个 RTCPeerConnectionFactory 的实例。

3.2 获得 TURN/STUN server 地址

随后,通过调用 appr.tc 的 API,获得 ICE server 地址,这个后面会用到。

1
2
3
4
5
6
7
8
9
10
// Request TURN.
__weak ARDAppClient *weakSelf = self;
[_turnClient requestServersWithCompletionHandler:^(NSArray *turnServers,
NSError *error) {
// 此处省略错误检查代码
ARDAppClient *strongSelf = weakSelf;
[strongSelf.iceServers addObjectsFromArray:turnServers];
strongSelf.isTurnComplete = YES;
[strongSelf startSignalingIfReady];
}];

3.3 加入房间,并连接上 WebSocket

加入房间,获得 room id,有服务端返回自己是发起者还是接受者(先加入房间的是发起者,后加入的是非发起者),并连接 websocket. 这里发起者和接受者的逻辑是一样的。

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
// Join room on room server.
[_roomServerClient joinRoomWithRoomId:roomId
isLoopback:isLoopback
completionHandler:^(ARDJoinResponse *response, NSError *error) {
ARDAppClient *strongSelf = weakSelf;

// 此处省略错误检查代码

RTCLog(@"Joined room:%@ on room server.", roomId);
strongSelf.roomId = response.roomId; // 获得 roomId
strongSelf.clientId = response.clientId;
strongSelf.isInitiator = response.isInitiator; // 服务器决定谁是发起者(先加入房间的是发起者,后加入的是非发起者)
for (ARDSignalingMessage *message in response.messages) {
if (message.type == kARDSignalingMessageTypeOffer ||
message.type == kARDSignalingMessageTypeAnswer) {
strongSelf.hasReceivedSdp = YES;
[strongSelf.messageQueue insertObject:message atIndex:0];
} else {
[strongSelf.messageQueue addObject:message];
}
}
// 获得 WebSocket 地址
strongSelf.webSocketURL = response.webSocketURL;
strongSelf.webSocketRestURL = response.webSocketRestURL;
[strongSelf registerWithColliderIfReady]; // 连接 WebSocket 服务器
[strongSelf startSignalingIfReady];
}];

3.4 创建 RTCPeerConnection 的实例

在上面的最后一个方法调用里,也就是 startSignalingIfReady 方法,开始主要的流程:

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
//RTCMediaConstraints 主要是描述音视频媒体的参数,比如分辨率大小,音频声道数等等 
RTCMediaConstraints *constraints = [self defaultPeerConnectionConstraints];
RTCConfiguration *config = [[RTCConfiguration alloc] init];
RTCCertificate *pcert = [RTCCertificate generateCertificateWithParams:@{
@"expires" : @100000,
@"name" : @"RSASSA-PKCS1-v1_5"
}];
config.iceServers = _iceServers; // 上面从服务器获得的 ICE server 地址
config.sdpSemantics = RTCSdpSemanticsUnifiedPlan; // 这里使用 unified plan
config.certificate = pcert;

// 创建 PeerConnection!!!
❶_peerConnection = [_factory peerConnectionWithConfiguration:config
constraints:constraints
delegate:self];

❷[self createMediaSenders];
if (_isInitiator) { // 只有发起方创建 offer,接收方只需等待对方的 offer,然后创建 answer 即可
// Send offer.
__weak ARDAppClient *weakSelf = self;

❸[_peerConnection offerForConstraints:[self defaultOfferConstraints]
completionHandler:^(RTCSessionDescription *sdp,
NSError *error) {
ARDAppClient *strongSelf = weakSelf;
[strongSelf peerConnection:strongSelf.peerConnection
didCreateSessionDescription:sdp
error:error];
}];
} else {
// Check if we've received an offer.
❹[self drainMessageQueueIfReady];
}

❶ 通过 RTCPeerConnectionFactory 创建 RTCPeerConnection 的实例,RTCPeerConnection 是核心类之一,把控从采集到发送这个流程。 ❷ createMediaSenders 方法的实现如下,首先根据 constraints 创建 source,接着用 source 创建 track,最后把创建好的 track 添加到 peer connection 里,到这一步,底层会自动创建 media sender,所以这个方法的名字叫 ‘createMediaSenders’

source 可以理解为音视频源,track 用来描述音视频轨道,sender 是用来发送音视频数据的类,这三个概念对于音视频是独立的。这里不多做描述,有兴趣的可以看一下源码。

RTCCameraVideoCapturer 类负责视频的采集,音频的采集是使用 SDK 默认的方法,这里没有单独创建。

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)createMediaSenders {
// 创建 local audio track,并添加到 peerconnection 中
RTCMediaConstraints *constraints = [self defaultMediaAudioConstraints];
RTCAudioSource *source = [_factory audioSourceWithConstraints:constraints];
RTCAudioTrack *track = [_factory audioTrackWithSource:source
trackId:kARDAudioTrackId];
[_peerConnection addTrack:track streamIds:@[ kARDMediaStreamId]];

// 创建 local video track 并添加到 peerconnection 中(最终会添加到 peerconnection.transceiver.sender 里)
_localVideoTrack = [self createLocalVideoTrack];
if (_localVideoTrack) {
[_peerConnection addTrack:_localVideoTrack streamIds:@[ kARDMediaStreamId]];
[_delegate appClient:self didReceiveLocalVideoTrack:_localVideoTrack];

// We can set up rendering for the remote track right away since the transceiver already has an
// RTCRtpReceiver with a track. The track will automatically get unmuted and produce frames
// once RTP is received.
RTCVideoTrack *track = (RTCVideoTrack *)([self videoTransceiver].receiver.track);
[_delegate appClient:self didReceiveRemoteVideoTrack:track];
}
}

// 只保留了核心逻辑,其他省略了
- (RTCVideoTrack *)createLocalVideoTrack {
//factory 创建一个新的 source
RTCVideoSource *source = [_factory videoSource];
// 用 source 创建一个 RTCCameraVideoCapturer,RTCCameraVideoCapturer 负责 iOS 上的采集,
// 采集到的视频帧发送给 delegate。RTCVideoSource 实现了 RTCVideoCapturerDelegate,
// 所以可以接收到 RTCCameraVideoCapturer 采集到的视频帧
RTCCameraVideoCapturer *capturer = [[RTCCameraVideoCapturer alloc] initWithDelegate:source];

// 这里的 delegate 就是 ARDVideoCallViewController,它收到消息后,开始通过 capturer 进行视频采集
[_delegate appClient:self didCreateLocalCapturer:capturer];

return [_factory videoTrackWithSource:source trackId:kARDVideoTrackId];
}

3.5 发起方,create offer,获得 SDP

❸ 发起方创建 offer,callback 里拿到 SDP,然后调 delegate 的 didCreateSessionDescription 回调方法,delegate 里 setLocalDescription,然后通过 signaling server(WebSocket)发送给 remote peer. setLocalDescription 之后,就启动了 ICE candidate gathering,gather 之后 delegate 就会收到 - (void)peerConnection:(RTCPeerConnection *)peerConnection didGenerateIceCandidate:(RTCIceCandidate *)candidate 方法的调用❺。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
- (void)peerConnection:(RTCPeerConnection *)peerConnection
didCreateSessionDescription:(RTCSessionDescription *)sdp
error:(NSError *)error {
dispatch_async(dispatch_get_main_queue(), ^{
//... 省略错误检查
__weak ARDAppClient *weakSelf = self;
// 设置 setLocalDescription
[self.peerConnection setLocalDescription:sdp
completionHandler:^(NSError *error) {
ARDAppClient *strongSelf = weakSelf;
[strongSelf peerConnection:strongSelf.peerConnection
didSetSessionDescriptionWithError:error];
}];
ARDSessionDescriptionMessage *message =
[[ARDSessionDescriptionMessage alloc] initWithDescription:sdp];
// 发送 SDP 到 remote peer
[self sendSignalingMessage:message];
// 设置 video sender 的最大码率
[self setMaxBitrateForPeerConnectionVideoSender];
});
}

3.6 接收方 create answer,获得 SDP

❹ 接收方在 drainMessageQueueIfReady 方法里处理 WebSocket 的消息,如果有发起方发来的 Offer 消息的话,则创建 Answer 发给对方。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
case kARDSignalingMessageTypeAnswer: {
ARDSessionDescriptionMessage *sdpMessage =
(ARDSessionDescriptionMessage *)message;
RTCSessionDescription *description = sdpMessage.sessionDescription;
__weak ARDAppClient *weakSelf = self;

// 把对方的 SDP 设置为 remote SDP
[_peerConnection setRemoteDescription:description
completionHandler:^(NSError *error) {
ARDAppClient *strongSelf = weakSelf;
// 回调 delegate
[strongSelf peerConnection:strongSelf.peerConnection
didSetSessionDescriptionWithError:error];
}];
break;
}

接收方 create answer,并把 SDP 发送给发起方。

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

- (void)peerConnection:(RTCPeerConnection *)peerConnection
didSetSessionDescriptionWithError:(NSError *)error {
dispatch_async(dispatch_get_main_queue(), ^{
// 省略错误处理
// If we're answering and we've just set the remote offer we need to create
// an answer and set the local description.
if (!self.isInitiator && !self.peerConnection.localDescription) {
RTCMediaConstraints *constraints = [self defaultAnswerConstraints];
__weak ARDAppClient *weakSelf = self;
// 创建 Answer
[self.peerConnection answerForConstraints:constraints
completionHandler:^(RTCSessionDescription *sdp, NSError *error) {
ARDAppClient *strongSelf = weakSelf;
// 回调 delegate 的 didCreateSessionDescription 方法(和发起者创建完 offer 回调的方法一致,行为也一致,
// 首先 setLocalDescription,然后发送到 remote peer,然后设置最大发送码率)
[strongSelf peerConnection:strongSelf.peerConnection
didCreateSessionDescription:sdp
error:error];
}];
}
});
}

- (void)peerConnection:(RTCPeerConnection *)peerConnection
didCreateSessionDescription:(RTCSessionDescription *)sdp
error:(NSError *)error {
dispatch_async(dispatch_get_main_queue(), ^{
//... 省略错误检查
__weak ARDAppClient *weakSelf = self;
// 设置 setLocalDescription
[self.peerConnection setLocalDescription:sdp
completionHandler:^(NSError *error) {
ARDAppClient *strongSelf = weakSelf;
[strongSelf peerConnection:strongSelf.peerConnection
didSetSessionDescriptionWithError:error];
}];
ARDSessionDescriptionMessage *message =
[[ARDSessionDescriptionMessage alloc] initWithDescription:sdp];
// 发送 SDP 到 remote peer
[self sendSignalingMessage:message];
// 设置 video sender 的最大码率
[self setMaxBitrateForPeerConnectionVideoSender];
});
}

3.7 获得 ICE candidate

didGenerateIceCandidate 回调里,不管事发起方还是接收方,都把获取到的 ICE candidate,通过 signaling server 发给 remote peer 即可, 对于获取到的 ICE candidate 无需做其他任何的处理。

1
2
3
4
5
6
7
8
- (void)peerConnection:(RTCPeerConnection *)peerConnection
didGenerateIceCandidate:(RTCIceCandidate *)candidate {
dispatch_async(dispatch_get_main_queue(), ^{
ARDICECandidateMessage *message =
[[ARDICECandidateMessage alloc] initWithCandidate:candidate];
[self sendSignalingMessage:message];
});
}

对方收到之后,添加到 peer connection 里,也无需做其他的处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
- (void)processSignalingMessage:(ARDSignalingMessage *)message {
NSParameterAssert(_peerConnection ||
message.type == kARDSignalingMessageTypeBye);
switch (message.type) {
//... 省略
case kARDSignalingMessageTypeCandidate: {
ARDICECandidateMessage *candidateMessage =
(ARDICECandidateMessage *)message;
[_peerConnection addIceCandidate:candidateMessage.candidate];
break;
}
//... 省略
}
}

同理,对方在 setLocalDescription 之后,也开始 gather ICE candidate,并把收集到的 ICE candidate 发给对方。当两端都有了双方的 candidate,就开始做连通性检查,找到一条双方都可以通信的通道之后(这一部分底层帮我们做了,如果是 Web 端程序,是浏览器做的,如果对连通性检查这块感兴趣,可以参考 rfc-8445 - 2.2 Connectivity Checks (https://tools.ietf.org/html/rfc8445#section-2.2)),双方即可相互传递发送数据了,至此彼此也可以看到对方了。

如果发生异常情况,比如网络条件恶化,单方或者双方建议切换到低分辨率或者修改编码器,将会触发新的一轮 ICE candidate 的收集和交换。

Web 端上的整个过程和这个很相似,区别是采集阶段,获取摄像头和麦克风的输入调用的是 getUserMedia() 方法,app 里是用的 native 的方式,比如 iOS 是用 RTCCameraVideoCapturer 采集视频的(内部实现是通过 AVCaptureSession 来实现的)。

安装和配置 WebRTC 的 STUN/TURN 服务 coturn

TURN server 可以解决点对点通信里的 NAT 穿透,并提供中继(relay) 的服务。coturn 是一个开源的 TURN 和 STUN 服务器,是基于 rfc5766-turn-server 进化过来的,目前比较成熟。之所以安装 coturn 是借助它提供的 TURN 服务,解决 WebRTC P2P 通信中 STUN 服务器解决不了的复杂内网的问题。比如我尝试过移动 4G 和 联通 WIFI 有时候无法通过 STUN 服务穿越内网连接,通过 TURN 服务的中继就可以解决这个问题。下面我们就来介绍一下 coturn 的安装,配置以及测试的过程。

Install coturn

coturn 安装可以参考官方的(安装文档)[https://github.com/coturn/coturn/blob/master/INSTALL],按该文档安装即可。如果是 Ubuntu 系列,可以直接通过 apt 安装,安装完成后会放在 /usr/bin/turnserver

1
apt install coturn

网络配置

以阿里云服务器为例,配置网络安全组 / 防火墙,开放 3478 的 UDP/TCP 端口,这个我们我们会作为 TURN/STUN 服务器的监听端口,并开放 40000~60000 的 UDP 端口,STUN 和 TURN 可能会使用随机的 UDP 端口。

配置 coturn

1. 生成证书

这里我们先简单使用自签名证书:

1
sudo openssl req -x509 -newkey rsa:2048 -keyout /opt/turnserver/turn_server_pkey.pem -out /opt/turnserver/turn_server_cert.pem -days 99999 -nodes

2. 配置 turnserver.config

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
listening-port=3478
listening-ip=172.17.86.xxx
external-ip=39.105.xxx.xxx
min-port=40000
max-port=60000
verbose
fingerprint
lt-cred-mech
user=joey:bluebluesky
realm=mytest
cert=/opt/ssl/turn_server_cert.pem
pkey=/opt/ssl/turn_server_pkey.pem
no-loopback-peers
no-multicast-peers
no-tcp
no-tls
no-cli

listening-port 是监听端口,默认谁 3478,这里我们显式地配上。值得注意的是,因为阿里云使用的是专有网络(基本上目前的云服务厂商都是类似的网络),网卡上绑定的 IP 不是外网 IP(ifconfig 得到的 IP 中不包含外网 IP),这个我咨询过客服,阿里云客服回答为了安全性,只提供专有网络。这里 listening-ip 需要填网卡绑定的 IP 地址,也就是 ifconfig 得到的地址,然后 external-ip 填外网的 IP,否则 WebRTC 获得的 relay IP 会是阿里云内网的 IP,导致连接失败。

另外这里我们配置了 long-term credential 方式的用户名和密码,这个是 WebRTC 需要的,这里简单起见,我们写死了: user=joey:bluebluesky。正式项目中,最好是使用 Time-Limited Credentials,通过 RESTful 的请求,从 TURN server 得到一个临时用户名和密码,这里简单起见,用long-term credential 的方式配置。

cert 和 pkey 就是我们前面步骤生成的,这里把相应的地址填上即可。

如果这个配置里面每一行的含义有疑问,可以参考 官方文档

3. 启动 coturn

在 turnserver.conf 所在的目录,执行 turnserver -c turnserver.conf 即可。

4. 测试 STUN/TURN 服务是否正常

有几种测试方式,如果你已经写好了 WebRTC 的 app,可以直接使用 turn:your-external-ip-address:3478 配置 ICE server(your-external-ip-address 需要换成刚才设置的外网 IP),用户名是 joey,密码是 bluebluesky。

1
2
3
4
5
6
7
8
9
10
var iceServers = {
iceServers: [
{
urls: 'turn:your-external-ip-address:3478',
username: 'joey',
credential: 'bluebluesky'
}]
};

connection = new RTCPeerConnection(iceServers);

这里我们先使用一种简单的方式,打开 WebRTC samples Trickle ICE,这个是官方的一个工具。打开后,把已有的 server 清空,添加 TURN URI: turn:your-external-ip-address:3478,TURN username: joey,TURN password:bluebluesky. 然后 Add Server 添加到 ICE server 组里。如下图:

-w489

然后点击下方的 Gather candidates,开始收集,IceTransports value 使用默认的 all 即可。coturn 默认启动是同时开启 STUN 和 TURN 服务的,所以下方如果出现的 type 有 srfix 就说明 STUN server 是正常工作的,其中 Protocol Address 表示当前的外网 IP。如果还出现了 relay 表示 TURN 服务器是正常的,其中 Protocol Address 表示 TURN 服务器的 IP 地址。

-w1049

如果 coturn log 中提示类似 9: session 001000000000000001: realm <mytest> user <>: incoming packet message processed, error 401: Unauthorized 的提示,可以忽略。这块我花了不少时间查这个问题,以为是密码输入错了,其实是没问题的,可以忽略,参考 这里

WWDC 2016 - Session 401 - What's New in Xcode App Signing 笔记

这篇 blog 来自我们内部的分享,内容比较精简,需要更多的细节信息,请参考 WWDC 2016 - 401 的视频

相信每一个开发者在初学 iOS 的时候,都有过被 Code Signing 坑过的经历,特别是当旁边没有人指导的时候,这也是当时我个人学习 iOS 的时候最困扰的地方,证书,provisioning profile, code signing 等等这些和实际的开发无关的概念,现在还记得苦苦看文档的经历。

Image1 icon
iOS 证书申请和签名打包流程图,图来自 这里

Xcode 团队在 Xcode 8 中移除了 fix issue 之后还需要 fix issue 但是可能还是不能 fix issue 的 Fix Issue 按钮,并完全重新设计了 code signing 的交互,流程和架构,不管对于 iOS 初学者还是有经验的程序员,都能大大简化 code signing 的流程,让我们把精力更专注于实际的业务开发上。

Xcode 8 支持两种签名方式,自动化签名 (Automatic Singing)和自定义签名(Customized Signing) 的。下面我们说一下基础概念和这两种签名方式。

1. 基础概念 (Fundamentals)

  1. 证书 (Certificate) 在 Xcode 8 之前,每个账号一般有两个证书,一个是开发证书,一个是发布证书。开发证书和发布证书都只能存在一份,所以如果有多台 Mac 开发设备,需要通过证书的导出导入来同步证书(和密钥)。在 Xcode 8 之后,支持多个 开发证书 (发布证书依然只能有一个),也就是说,多台 mac 开发设备可以 自动 生成多份有效的开发证书(和密钥),就不再需要导出导入了。(这里有雷鸣般的掌声)

  2. Provisioning profile 用来授权给包含在 profile 里的 iOS 开发设备来安装 app。在 Xcode 8 之前,每次添加新的设备都会生成新一个新的 profile,并产生一个唯一的 id,所以在每次添加设备之后,因为 profile id 变了,需要更新并提交 project 文件,Xcode 8 以后是用文件名的方式引用,就是说添加了新的设备,只要 profile 文件名不变,就不会修改 project 文件了。这也算是一个比较方便的改进吧。(掌声)

  3. Entitlement 其实就是管理我们开启的 Capabilities,比如 IAP,Push Notifications,iCloud 等等

  4. 签名的流程 首先 Xcode 根据所择的 team 从 key chains 里选择 最新的 证书,然后根据 app identifier 选择 最新的 provisioning profile,在 build 的时候 profile 会被放在 app 包里,code sign 工具根据证书生成一个 code seal(可以理解为盖一个戳)。如果有人篡改了 app,这个戳就不 match 了,iOS 系统会阻止 app 安装。

想了解更多?代码签名探析 @objc.io

2. 自动化签名 (Automatic Singing)

在这种模式下,Xcode 全自动的为我们管理整个签名的流程,整个过程会在后台执行,会保证所有签名需要的文件是最新的。

Image1 icon

我们所需要做的就是勾选上自动化签名,然后选择 team。剩下的 Xcode 都会接管。比如创建证书,创建和更新 profile 等等。但是当插入了一台新的 iOS 设备,Xcode 8 还是会提示是否把这台设备添加到测试设备中,如果选择是,Xcode 8 会自动添加到设备列表里,并自动更新 profile 文件。(鼓掌)

如果比较好奇 Xcode 自动为我们做了什么,可以在 Reports 里看查看 log, (鼓掌) 比如:
Image1 icon

Xcode 自动化签名只会自动化开发阶段的签名,不会修改发布的签名设置。既然这样,如何设置 release 版本的签名呢?其实我们在 Archive 的时候,Xcode 默认使用的还是开发证书做的签名,然后在 Orgnizer 里选择 export 到 App Store 发布版本的时候,会让我们重新选择 证书重新签名,这里再选择发布证书(演讲者这里说的是开发证书,应该是口误)。

3. 自定义签名(Customized Signing)

如果我们想自己管理签名所需的文件,可以选择自定义签名方式。这种模式下,Xcode 不会对签名设置做任何的修改。

操作很简单,就是取消勾选自动化签名,然后就可以对每个 build configuration 做不同的签名设置了,注意不用去 Build Setting 里设置了,直接 General 里就可以完成签名的设置了。如下图,对免费版和收费版设置不同的 profile:
Image1 icon

虽然我们设置了自定义签名,但 Xcode 并不是真的什么都不做了,相反如果签名的设置有问题, Xcode 提供更多友好和精确的提示:
Image1 icon
Image1 icon

4. 最佳实践 (Best Practices)

blah blah...
一句话:使用自动化签名 (to make your life as easy as possible)
blah blah...

Enjoy!

揭开 Monad 的神秘面纱

我们知道 Swift 语言支持函数式编程范式,所以函数式编程的一些概念近来比较火。有一些相对于 OOP 来说不太一样的概念,比如 Applicative, Functor 以及今天的主题 Monad. 如果单纯的从字面上来看,很神秘,完全不知道其含义。中文翻译叫做 单子,但是翻译过来之后对于这个词的理解并没有起到任何帮助。

我的理解很简单,Functor是实现了 map 函数的容器,Monad 就是实现了 flatMap 方法的容器,比如在 Swift 里,Optional, CollectionType 等等都可以称为 Monad。

既然有了 map, flatMap 又有什么作用呢?两者有什么联系和区别呢?

map vs flatMap

map 和 flatMap 的共同点都是接受一个 transform 函数,把一个容器转换为另外一个容器。

下面主要从 维度 这一块来解释两者的区别,我们先来简单的定义一下 维度

对于类型 T,如果类型 S 是一个容器,且元素的类型包含 T,那我们就说:
S(维度) = T(维度) + 1

举个🌰, [Int] (维度) = Int (维度) +1, Int? (维度) = Int(维度) + 1.

map 和 flatMap 的区别是,对于 map,容器里的一个元素经过 transform 后只产生一个元素,是 one-to-one 的关系,也就是说经过转换后,纬度是不变的。比如:

1
2
3
4
5
var intArray: [Int] = [1, 2, 3, 4, 5]
var stringArray: [Int] = intArray.map { (value: Int) -> String in
return "\(value)"
}
//stringArray: ["1", "2", "3", "4", "5"]

这个 transform 函数的是 Int -> Int 的,两边的维度是一致的。

对于 flatmap,容器里的一个元素经过 transform 可能转换成 0 个,1 个 或者多个元素,也就是 one-to-any 的关系,既然是 any 的关系,就需要一个容器来存放 any 个元素,所以经过 transform 的返回值通常是一个容器,所以 transform 函数执行之后,相当于维度+1。

1
2
3
4
var oddIntArray: [Int] = intArray.flatMap { (value: Int) -> Int? in
return value % 2 == 1 ? value : nil
}
//oddIntArray: [1, 3, 5]

这里的 transform 是 Int -> Int? 的,我们知道 Int? 是 Int 的包装类型,所以说 transform 相当于对每个元素都包了一层,提升了一个维度.

但是我们看一下上面例子里 stringArray 和 oddIntArray 的类型,都是 [Int],也就是说 flatMap 函数对 transform 函数的返回值做了降维处理。那么 flat 的意思在这里也就知道了,就是把 transform 返回的容器 降维攻击(拍扁),拿出里面的元素。

flatMap 函数为什么要这么做呢?在函数式编程中,通常会对一个值 / 操作进行链式操作,为了保证后面还可以继续方便的进行链式操作,一般需要保持维度不变。其实可以看作一个约定,大家都遵循一定的规则,才都有得玩。

如何确定使用 map or flatMap 的时机?

从上面可以看到 map 对 transform 的返回值没有做特殊的处理,flatMap 对于 transform 的返回值会做降维处理,比如 unwrap optional 值等。

其实可以反推,如果给定的 transform 函数会对调用者容器里的每个元素做升维,那我们需要用 flatMap 对它的结果进行降维,来保证调用 flatMap 前后维度保持一致。如果说 transform 调用前后维度没有变化,使用 map 方法就行了。

Swift 中的 map 和 flatMap 方法

首先看看 Optional<Wrapped> 的 map 和 flatMap 方法:

1
2
3
4
5
6
/// If `self == nil`, returns `nil`.  Otherwise, returns `f(self!)`.
@warn_unused_result
public func map<U>(@noescape f: (Wrapped) throws -> U) rethrows -> U?
/// Returns `nil` if `self` is `nil`, `f(self!)` otherwise.
@warn_unused_result
public func flatMap<U>(@noescape f: (Wrapped) throws -> U?) rethrows -> U?

map 的 transform 是 Wrapped -> U 维度不变, flatMap 的 transform 方法是 Wrapped -> U?,维度 +1。因为 Optional 的特殊性,flatMap 提供了 one-to-zero/one 的关系。

继续看看 CollectionType:

1
2
3
4
5
public func map<T>(@noescape transform: (Self.Generator.Element) throws -> T) rethrows -> [T]

public func flatMap<T>(@noescape transform: (Self.Generator.Element) throws -> T?) rethrows -> [T]

public func flatMap<S : SequenceType>(transform: (Self.Generator.Element) throws -> S) rethrows -> [S.Generator.Element]

有一个 map 函数和两个 flatMap, map 的 transform 函数是 Element -> T 维度不变,两个 flatMap 的 transform 函数分别是 Element -> T?(one-to-zero/one) 和 Element -> S: SequenceType, SequenceType 是个集合,相当于 one-to-any,这两个 transform 维度都升了一级。

特别感谢我的同事 王轲, 本文的很多思路都得益于和他的讨论。

把 Blog 从 Jekyll 迁移到 Hexo

花了几个小时把 Blog 平台引擎从 Jekyll 迁移到了 Hexo 上,换成 Hexo 的主要原因是 Jekyll 用起来总是出问题,比如提交到 GitHub 上生成的静态页速度很慢,以及一些莫名其妙的错误,和对 MarkDown 支持的比较差等等。

找了一圈综合对比,最终发现 Hexo 能完全满足我的需求,功能强大,插件和主题比较丰富,支持多种平台一键部署,比如 GitHub Pages, Heroku, AS3, 甚至直接 rsync, 有了之前的教训,我果断选择了通过 rsync 放在了自己闲置的一台 Digital Ocean 的 VPS 上。更吸引我的是 Hexo 是基于 NodeJS 的,我对 Node 和 EJS 模版引擎比较熟悉,有无法满足的需求时,就直接顺手添加上了,比如这次分页的方式,对友言的评论的支持,以及 UI 上的一些改动等等。

<!-- more -->

主题比较多,试了几个,发现还是比较喜欢简洁的风格,太花里胡哨不容易把精力集中到阅读本身上。基于这个初衷,选择了 Hacker 主题,稍微改了一点点样式。插件比较喜欢的是 hexo-filter-auto-spacing,可以自动在中日韩文和西文之间补上空格,懒癌发作的时候就可以省去敲空格的麻烦了。

如果你厌倦了 Jekyll 或者 Octopress,个人推荐可以尝试一下 Hexo.

Swift 之类型的协变与逆变

今天科比正式退役,在未来的日子里,也许还会有像科比一样有天赋又努力的球员出现,但我们却不再有青春去追随了。 --- 沃茨•其索特

1 什么是协变与逆变

刚开始看到协变 (Covariance) 和逆变 (Contravariance) 的时候,差点晕菜,反复查了一些资料,才稍有些自己的体会,难免有理解不对的地方,欢迎指出 :]

在计算机科学和类型的领域内来看,变化 (variance) 这个词指的是两个类型之间的关系是如何影响从它们衍生出的两种复杂类型之间的关系的。相对于原始类型,这两种复杂类型之间的关系只能是不变 (invariance),协变(covariance) 和逆变 (contravariance) 之中的某一种。

这段比较拗口,我们一步一步拆解,既然上面提到了两个类型之间的关系,在主流的编程观念里,类型之间的关系中通常会包含子类型(subtype) 和 父类型(supertype)。

首先假设 Cat 是 Animal 的子类,就是说 Cat 是 Animal 的 subtype,可以看作上面的“原始类型”,然后有两个衍生出来的 List<Cat>List<Animal>类型,就是从 Cat 和 Animal 衍生出来的两种复杂类型。

那么我们就可以这么来解释协变和逆变了:

  • 协变: 如果说 List<Cat> 也是 List<Animal>的 subtype,也就是衍生类型的关系和原来类型( Cat 与 Animal)的关系是一致的,那我们就说 List 是和它的原来类型协变(共同变化)的。
  • 逆变:如果说 List<Cat>List<Animal>supertype,也就是衍生类型的关系和原来类型( Cat 与 Animal)的关系是相反的,那我们就说 List 是和它的原来类型逆变(反变)的。
  • 不变:如果说 List<Cat> 既不是 List<Animal> 的 subtype,也不是 supertype,也就是说没有关系,则说是不变的。

2 为什么要了解协变与逆变?

我们知道 subtype 是可以替换 supertype 的,反之则不行,比如说:

1
2
let animal: Animal = Cat();  //Right
let cat:Cat = Animal(); //Wrong

来看不同返回值类型的函数替换:

1
2
3
4
5
func animalF() -> Animal { return Animal() }
func catF() -> Cat { return Cat() }

let returnsAnimal: () -> Animal = catF //Right
let returnsCat: () -> Cat = animalF //Wrong

第一个赋值语句通过编译是正确的 () -> Cat 和 () -> Animal 的关系与 Cat 和 Animal 之间的关系一致,也就是说是在 Swift 中函数的返回值是协变的。

再看看不同参数的函数的变化:

1
2
3
4
5
func printCat(cat: Cat) -> Void { print("\(cat)") }
func printAnimal(animal: Animal) -> Void { print("\(animal)") }

let logCat: Cat -> Void = printAnimal //Right
let logAnimal: Animal -> Void = printCat //Wrong

我们先不运行这段代码,从 caller 角度思考一下两个赋值语句可能的结果,假设我们要调用 logCat(Cat()) ,实际会执行 printAnimal: Animal -> Void 函数,printAnimal 是能接受 Cat 类型的参数的,运行应该没有问题。

然后如果调用 logAnimal(Animal()),实际会运行 printCat: Cat -> Void 函数,但是我们发现 printCat 理论上无法接受一个 Animal 的对象,因为它是 Cat 的父类.

我们可以看到函数 Animal -> Void 可以替换 Cat -> Void,反之行不通,也就是说 Animal -> Void 是 Cat -> Void 的 subtype,和 Animal 是 Cat 的关系是 supertype 是相反的!也就是说函数的参数是逆变的。

得到的结论是: 函数的参数是逆变的,返回值是协变的。 我们知道了变化的规则,就能判断出类型的关系,就可以知道一个类型是否可以替换另外一个类型。

思考下面这些 testCatAnimal 函数调用那些是正确的,如果把 testCatAnimal 换成 testAnimalCat 呢?

1
2
3
4
5
6
7
8
9
10
11
func testCatAnimal(f: (Cat -> Animal)) { print("cat -> animal") }

func catAnimal(cat: Cat) -> Animal { return Animal();}
func catCat(cat: Cat) -> Cat { return Cat(); }
func AnimalCat(animal: Animal) -> Cat { return Cat(); }
func AnimalAnimal(animal: Animal) -> Animal { return Animal(); }

testCatAnimal(catAnimal)
testCatAnimal(catCat)
testCatAnimal(AnimalCat)
testCatAnimal(AnimalAnimal)

3. 其他类型的协变和逆变

上面我们提到了函数的参数和返回值的分别是逆变和协变,在 Swift 中除了函数,还有属性 (property),范型(Generic) 等。

对于属性来说,如果是 readonly 的,属性是协变的,子类如果要覆盖,必须是父类属性的 subtype。如果是 readwrite 的,属性是不变的,子类必须和父类的属性类型完全一致。

对于范型来说,范型本身其实没有特殊的变化,它的变化与范型使用的环境紧密相关,如果是用作函数的返回值或者覆盖父类的 readonly 属性,它的协变的,如果用做函数的参数,它是逆变的,如果是用做覆盖父类的 readwrite 的属性,或者同时用做函数的返回值和参数,那它必须是不变的,也就是说范型类型必须和要求完全一致,不能使用 subtype 或者 supertype.

Reference

  1. Swift 2.1 Function Types Conversion: Covariance and Contravariance

  2. Friday Q&A 2015-11-20: Covariance and Contravariance

动态加载 FLEX 的越狱插件 - FLEXLoader

介绍

FLEXLoader 是一个我在上周末写的一个可以动态加载 FLEX 的开源越狱插件,它以加载动态库的方式注入到系统 App 和用户的 App 中 (欢迎使用 star, fork, clone 等一切方法蹂躏我~~)。FLEX 全称是 "Flipboard Explorer",是 Flipboard 团队开发一组调试和探测 App 的开源工具,功能非常强大,比如查看和修改 View 的层级结构,查看和修改堆内存中的对象信息等等,更多 FLEX 介绍和使用信息参考 这里

FLEXLoader 参考了 RevealLoader,顾名思义,它是一个加载 Reveal 动态库的越狱插件,是一款非常方便的插件,如果你经常用 Reveal 来查看和调试,一定不要错过。我把它的源码做了一些修改,把 Reveal 的动态库改成了 FLEX 的动态库,因为 FLEX 官方只提供了源代码,所以我参考了 Tony 的这篇 文章 编译了一个动态库,如有有兴趣,也可以直接用我已经构建好的 Xcode 工程 FLEXDynamicLibProject 来编译。

安装 FLEXLoader

有下面两种安装方式:

  1. 在 Cydia 中搜索 Flipboard FLEX loader 并安装(BigBoss 源)
  2. 如果安装有越狱的开发环境,比如 theos,可以自己来编译安装,配好环境变量后,make package install一下(也可以自己编译 FLEX 的动态库替换掉工程中的FLEXDylib.dylib).

使用方法

安装后,打开“设置”-> "FLEXLoader"->“Enabled Applications”, 勾选上你想要注入 FLEX 的 App,打开 App 就能看到 FLEX 的身影了,简直不能再简单了,:]

后记

写完这个 tweak 后,不敢也不能独享,心怀忐忑地放到了 GitHub 上,然后就打算放到 Cydia 上。Cydia 的诸多源中,感觉 BigBoss 最值得信赖一点,所以就打算传到 BigBoss 上,后来证明这个选择是非常正确的。从搜索 BigBoss 的网址,到填写表单上传完成,前后不到 10 分钟,甚至都没要求我注册,这个体验还是蛮爽的。

BigBoss 承诺 24 小时之后会处理,到了第二天,BigBoss 的审核员 @0ptimo 就给我发邮件,说 tweak 被拒掉了,原因是我没有把 FLEX 的 license 加上,这个确实是我疏忽了,我把 RevealLoader 的 license 加上却忘了 FLEX 的,于是就速度加上,然后名字和现有的一个叫 Flex 比较相似,建议我改一下名字,还有一些细节比如 icon 的名字直接叫 icon.png 容易被别人覆盖掉,动态库的位置放到 /Library/Application Support/FLEXLoader 比较好等等。我表示了感谢,然后都一一修改之后提交,过了不到一天就通过审核了。

如果你有好的想法或者问题,欢迎 PR 或者联系我. 最后感谢下面 REF 中的各位开源项目和文章的作者,他们才是创造者,我只是开源代码的组装工~~

REF

欢迎小伙伴在 微博 上关注我, :],Enjoy!

Swift 之 @auto_closure

用 C 实现一个assert(),通常是这么做的:

1
2
3
4
5
6
7
8
#ifdef NDEBUG
#define assert(e) ((void)0)
#else
#define assert(e) \
((void) ((e) ? ((void)0) : __assert (#e, __FILE__, __LINE__)))
#define __assert(e, file, line) \
((void)printf ("%s:%u: failed assertion `%s'\n", file, line, e), abort())
#endif
assert 就是断言,这里采用条件编译,作用是如果在调试情况下,检查参数 e,如果是 false,就给出错误提示并终止程序执行,如果是非 DEBUG 情况下,就什么都不做。这种宏实现的方式是没有运行时性能影响的,因为我们知道宏展开基本是直接替换的,没有对表达式求值的过程。

比如这样简单的一个宏,用来返回两个数中的较大值:

1
#define MAX(A,B) (A >= B ? A : B)
当我们使用的时候,比如MAX(10, 20), 宏展开后的结果是(10 >= 20 ? 10 : 20), 而不是计算到最终的结果20. 但是在方法调用中,参数值是直接求值的,比如我们有个判断一个数是否偶数的函数:
1
2
3
func isEven(num : Int) -> Bool {
return num % 2 == 0;
}
当我们调用 isEven(10 + 20) 的时候,先计算 10 + 20 的结果,然后把 30 作为参数传递到 isEven 函数中。

OK. 在 Swift 里也实现了这样一个功能的 assert()函数,而且没有用到宏 (你骗人,明明用到了啊?!, 就是#if !NDEBUG 啊。 好吧,相信苹果 Swift 官方 Blog 在下一篇文章中应该会有相应的机制来判断当前的环境的,这里的意思是没用宏来实现表达式的延迟求值。),是怎么实现的呢?

首先在 Swift 里没有办法写一个函数,它接受一个表达式作为参数,但是却不执行它。比如,我们想这么实现:

1
2
3
4
5
6
func assert(x : Bool) {
#if !NDEBUG

/*noop*/
#endif
}
然后这么用:
1
assert(someExpensiveComputation() != 42)
我们发现,总是要计算一遍表达式 someExpensiveComputation() != 42 的值,是真是假, 然后把这个值传递到 assert 函数中。即便我们在非 Debug 的情况下编译也是一样,那怎么样条件执行呢,像上面的使用宏的方式,当条件满足的时候才对表达式求值? 还是有办法的,就是修改这个方法,把参数类型改为一个闭包,像这样:
1
2
3
4
5
6
7
func assert(predicate : () -> Bool) {
#if !NDEBUG
if predicate() {
abort()
}
#endif
}
然后调用的时候创建一个匿名闭包,然后传给 assert 函数:
1
assert({ someExpensiveComputation() != 42 })
这样当我们禁用 assert 的时候,表达式someExpensiveComputation() != 42 就不会被计算,减少了性能上的消耗,但是显而易见,调用的代码就显的不那么清爽优雅了。

于是乎 Swift 引入了一个新的 @auto_closure 属性,它可以用在函数的里标记一个参数,然后这个参数会先被隐式的包装为一个 closure,再把 closure 作为参数给这个函数。好绕啊,直接看代码吧,使用 @auto_closure, 上面的 assert 函数可以改为:

1
2
3
4
5
6
7
func myassert(predicate : @auto_closure () -> Bool) {
#if !NDEBUG
if predicate() {
abort()
}
#endif
}
然后我们就可以这么调用了:
1
assert(someExpensiveComputation() != 42)
哇。好神奇!

仔细看一下 myassert()函数的参数:

1
predicate : @auto_closure () -> Bool
predicate 加上了 @auto_closure 的属性,后面是个 closure 类型 () -> Bool。其实 predicate 还是() -> Bool 类型的,只是在调用者可以传递一个普通的值为 Bool 表达式,,然后 RunTime 会自动把这个表达式包装为一个 () -> Bool 类型的闭包作为参数传给 myassert()函数,简而言之就是中间多了一个由表达式到闭包的自动转换过程。

@auto_closure的功能非常强大和实用,有了它,我们就可以根据具体条件来对一个表达式求值,甚至多次求值。在 Swift 的其他地方也有 @auto_closure 的身影,比如实现短路逻辑操作符时,下面是 && 操作符的实现:

1
2
3
func &&(lhs: LogicValue, rhs: @auto_closure () -> LogicValue) -> Bool {
return lhs.getLogicValue() ? rhs().getLogicValue() : false
}
如果 lhs 已经是 false 了,rhs 也就没有必要计算了,因为整个表达式肯定为 false。这里使用 @auto_closure 就轻松实现了这个功能。

最后,正如宏在 C 中的地位一样,@auto_closure的功能也是非常强大的,但同样应该小心使用,因为调用者并不知道参数的计算被影响 (推迟) 了。@auto_closure故意限制 closure 不能有任何参数(比如上面的() -> Bool),这样我们就不会把它用于控制流中。

编译自 Swift 的官方 Blog Building assert() in Swift, Part 1: Lazy Evaluation一文