0%

React Native (一):基础

React Native (二):StatusBar 、 NavigationBar 与 TabBar

React Native (三):自定义视图

React Native (四):加载新闻列表

React Native (五):上下拉刷新加载

React Native (六):加载所有分类与详情页

项目地址

React Native App 的视图结构

首先把 setup.jsRoot 以及 布局 的代码改一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Root extends React.Component {
render() {
return (
<View style={styles.container}>

</View>
);
}
}


const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: 'white',
},

});

React Native 的所有界面都是在一个主视图上,这里的这个 <View>iOS 中的 Window 是同样的,这里跟 Android 不太一样。

当然运行在 iOSAndroid 中还是以 ViewControllerActivity 展示的。

StatusBar

iOSAndroidStatusBar 是差不多的,都是顶部那高度 20 的部分,用来显示信号、电量等系统的信息。

setup.js 中加入 StatusBar

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import {
StyleSheet,
Text,
View,
StatusBar
} from 'react-native';

class Root extends React.Component {
render() {
return (
<View style={styles.container}>
<StatusBar
barStyle={'light-content'}
backgroundColor={'#000000'}
/>
</View>
);
}
}

StatusBar 的配置可以看这里,注意有些属性只针对特定平台。

这样可以设置整个 AppStatusBar 的样式,如果有某个页面 AStatusBar 需要特殊处理,那么就在 A 页面加入 StatusBar 进行设置。

我们需要用 Navigator 来让整个 App 可以在页面之间跳转。

NavigatoriOS 的一样,都是对页面进行 pushpop ,跟 AndroidNavigationView 不是一个东西。

官方文档在这里

现在在 App 文件夹内创建一个新的文件 App.js

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
'use strict';


import React from 'react'

import {
StyleSheet,
View,
Text

} from 'react-native';

export default class App extends React.Component {

render() {
return (
<View style={styles.view}>
<Text>main</Text>
</View>
)
}
}

const styles = StyleSheet.create({
view: {
flex: 1,
backgroundColor: 'white',
alignItems: 'center',
justifyContent: 'center'

},
});


然后在 setupStatusBar 下面加入 Navigator :

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
import App from './App'


<Navigator
initialRoute={{ component: App }}

style={{flex:1}}
renderScene={(route, navigator) => {
BackAndroid.addEventListener('hardwareBackPress', function() {
if(navigator == null){
return false;
}
if(navigator.getCurrentRoutes().length === 1){
return false;
}
navigator.pop();
return true;
});

let Component = route.component;
return <Component {...route.params} navigator={navigator}/>
// 上面的route.params 是为了方便后续界面间传递参数用的
}}
/>

其中 initialRoute 设置了 component ,是最底层的第一个页面。

然后 renderScene 控制 push 的时候做哪些操作,这里的 BackAndroid.addEventListener 是监听了 Android 的物理返回按键。

现在运行可以看见页面中间有个 main

你可能会发现为什么没 NavigationBar 呢,我们后面再加。

tab-navigator

官方并没有 TabBar ,我们使用一个第三方 react-native-tab-navigator

在项目根目录执行命令

1
npm install react-native-tab-navigator --save

安装完成后在 App.js 加入 TabNavigator:

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

const home_key = 'home'
const satin_key = 'satin'
const setting_key = 'setting'


import Home from './Home/Home'
import Satin from './Satin/Satin'
import Setting from './Setting/Setting'


<TabNavigator>
<TabNavigator.Item
selected={this.state.selectedTab === home_key}
title="首页"
titleStyle={styles.tabText}
selectedTitleStyle={styles.selectedTabText}
renderIcon={() => <Image style={styles.icon} source={require("./Images/root/home_unselect.png")} />}
renderSelectedIcon={() => <Image style={styles.icon} source={require("./Images/root/home_select.png")} />}
onPress={() => this.setState({ selectedTab: home_key })}>

<Home navigator={this.props.navigator}/>
</TabNavigator.Item>
<TabNavigator.Item
selected={this.state.selectedTab === satin_key}
title="段子"
titleStyle={styles.tabText}
selectedTitleStyle={styles.selectedTabText}
renderIcon={() => <Image style={styles.icon} source={require("./Images/root/activity_unselect.png")} />}
renderSelectedIcon={() => <Image style={styles.icon} source={require("./Images/root/activity_select.png")} />}
onPress={() => this.setState({ selectedTab: satin_key })}>

<Satin navigator={this.props.navigator}/>
</TabNavigator.Item>
<TabNavigator.Item
selected={this.state.selectedTab === setting_key}
title="我的"
titleStyle={styles.tabText}
selectedTitleStyle={styles.selectedTabText}
renderIcon={() => <Image style={styles.icon} source={require("./Images/root/my_unselect.png")} />}
renderSelectedIcon={() => <Image style={styles.icon} source={require("./Images/root/my_select.png")} />}
onPress={() => this.setState({ selectedTab: setting_key })}>

<Setting navigator={this.props.navigator}/>
</TabNavigator.Item>
</TabNavigator>

配置也比较易懂。

另外创建 3 个文件 Home.jsSatin.jsSetting.js

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
import React from 'react'

import {
View,
StyleSheet
} from 'react-native'


export default class Satin extends React.Component {
render() {
return (
<View style={styles.view}>

</View>
)
}
}

const styles = StyleSheet.create({
view: {
flex:1,
backgroundColor: 'orange'
}
})

可以给每个设置不同的颜色,这样点击界面切换也很明显。

具体的代码可以看 项目

同意官方也没有提供 NavigationBar,我们需要自定义一个。

然后在 Home.jsSatin.jsSetting.js 加入

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


import React from 'react'

import {
View,
StyleSheet
} from 'react-native'

import NavigationBar from '../Custom/NavBarCommon'


export default class Home extends React.Component {
render() {
return (
<View style={styles.view}>
<NavigationBar
title="首页"
unLeftImage={true}
/>

</View>
)
}
}

const styles = StyleSheet.create({
view: {
flex:1,
backgroundColor: 'white'
}
})

navibar

项目地址

React Native (一):基础

React Native (二):StatusBar 、 NavigationBar 与 TabBar

React Native (三):自定义视图

React Native (四):加载新闻列表

React Native (五):上下拉刷新加载

React Native (六):加载所有分类与详情页

最近刚完成一个 React Native 的项目,踩了无数坑,去年折腾过几周,后来因为有两个 iOS 项目就没有再折腾,当时想要写一个主文件,分别给 AndroidiOS 引用,但是弄了好久也不会弄,也下载了 f8app 看了,不过完全看不懂,就这样放弃了。

今年 3 月中旬正好公司有个项目只需要前端,而且公司没有 Android 开发,也想弄混合开发,于是正好我拿这个项目去练手。

从上面的描述也看出来我是一个 iOS 开发,对于有移动开发基础的人来说做 React Native 开发还是比较好上手的,前端的话更容易,只是刚开始入门比较费劲,也找不到个能问的人。总的下来我的建议还是如果卡在某个地方很久很烦躁不想继续学了,那么放一放,放几天或者一两周,再继续学,不要轻易放弃,入门后就轻松多了。

1.说明

这系列文章主要还是针对有编程经验的开发者,起码掌握一门主流的编程语言。

开发平台: Mac OS

IDE: WebStorm

这里我并不会很详细的一步一步的讲解,详细的教程可以看官方教程,这里主要是记录一些我在学习过程中遇到的疑难杂症,如果你碰到了或许可以帮你一把。

2.基础

首先既然看这个文章,那么默认你已经知道什么是 React Native 以及是干什么的,还有你需要会一些 JavaScript ,如果你还不会 JavaScript ,那么推荐 廖雪峰老师JavaScript教程阮一峰老师ECMAScript 6 入门

官方文档永远是必看的,对于初学者来说不会的先去官方文档找。React Native中文网 对于英文不好的同学来说是首选,比如我自己。

有问题也可以去 React NativeGitHub地址issues 找。

3.搭建开发环境

这部分比较简单,按着 官方教程 搭建即可,但是在运行命令的时候可能会出各种问题,这个时候只能靠 BaiduGoogle 了,我是一次成功的,所以也不知道会有什么问题,这里也不做过多说明了。

希望没人在这一步就放弃了。

4.iOS 和 Android 调用统一资源

新创建的项目 iOSAndroid 代码是分开的,分别在 index.ios.jsindex.android.js 中,这是两个平台的入口。

顶部的各种 import 是引入的各种资源:

1
2
3
4
5
6
7
import React, { Component } from 'react';
import {
AppRegistry,
StyleSheet,
Text,
View
} from 'react-native';

接下来是 React 的语法,创建了一些视图:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
export default class ReactNative1 extends Component {
render() {
return (
<View style={styles.container}>
<Text style={styles.welcome}>
Welcome to React Native!
</Text>
<Text style={styles.instructions}>
To get started, edit index.ios.js
</Text>
<Text style={styles.instructions}>
Press Cmd+R to reload,{'\n'}
Cmd+D or shake for dev menu
</Text>
</View>
);
}
}

然后是布局的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#F5FCFF',
},
welcome: {
fontSize: 20,
textAlign: 'center',
margin: 10,
},
instructions: {
textAlign: 'center',
color: '#333333',
marginBottom: 5,
},
});

最后是注册,每个平台只需要注册一次:

1
AppRegistry.registerComponent('ReactNative1', () => ReactNative1);

上面的那一大堆不用去管,我们就从这个方法入手,这个方法传入了 2 个参数,第一个是 App 的标志,这个你们应该也明白不能随便改,第二个参数是一个匿名方法,调用这个方法会返回 ReactNative1 ,就是上面的 React 创建的类,那么要让 iOSAndroid 引用同一个资源,只需要这里返回给同一个类即可。

新创建一个文件夹 App 或者随便啥,我们写的所有 JS 文件都放这里,方便管理。

然后在 App 内创建一个 setup.js,这时候目录看起来是这样子:

目录

然后在把 index.ios.jsimportReact布局 部分的内容复制过来,然后加入两句代码,现在 setup.js 文件是这样的:

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
import React from 'react'
import {
StyleSheet,
Text,
View
} from 'react-native';


function setup(): ReactClass<{}> {

//这里做一些注册第三方等App初始化需要的操作

return Root
}

class Root extends React.Component {
render() {
return (
<View style={styles.container}>
<Text style={styles.welcome}>
Welcome to React Native!
</Text>
<Text style={styles.instructions}>
To get started, edit index.android.js
</Text>
<Text style={styles.instructions}>
Double tap R on your keyboard to reload,{'\n'}
Shake or press menu button for dev menu
</Text>
</View>
);
}
}


const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#F5FCFF',
},
welcome: {
fontSize: 20,
textAlign: 'center',
margin: 10,
},
instructions: {
textAlign: 'center',
color: '#333333',
marginBottom: 5,
},
});


module.exports = setup

我这里是直接导入了 import React from 'react' ,那么在创建 React类 的时候就需要 extends React.Component ,我是习惯这种写法,如果你觉得官方的写法比较好,那么就按着官方的写就行。

然后修改 index.ios.jsindex.android.js ,修改成一模一样:

1
2
3
4
5
6
7
8
import {
AppRegistry,
} from 'react-native';

import setup from './App/setup'

AppRegistry.registerComponent('ReactNative1', setup);

如果已经在运行,那么 iOS模拟器command + R 刷新,Android模拟器 双击 R 刷新。

如果没有在运行那么运行 react-native run-iosreact-native run-android 查看效果。

如果运行的时候遇见这个错误:

firstError

那么需要关闭 react-native 启动的服务,重新启动。

iterm

如果一切正常,那么尝试修改 setup.jsText 标签中的文字,刷新 iOSAndroid 看看效果。

如果你坚持到了这里,那么恭喜你已经初步掌握了 react native

示例项目地址

参考链接

f8app

React Native中文网

https://github.com/facebook/react-native/issues/6613

好久没写东西了,一直在忙着学 React native

今天给 Android 导入百度地图 的时候遇见定位监听不回调的问题,记录一下。

电脑系统:Mac OS 10.12.4

IDE: Android Studio 2.1.2

JRE: 1.8.0_121-b13 x86_64

手机:Mi-4c

Android Version: 5.1.1LMY47V

1.确定 百度key 设置正确

meta-data 要放在 application 里面。

1
2
3
4
5
<application>  
<meta-data
android:name="com.baidu.lbsapi.API_KEY"
android:value="开发者 key" />
</application>

2.确定权限都添加

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<!-- 这个权限用于进行网络定位-->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"></uses-permission>
<!-- 这个权限用于访问GPS定位-->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"></uses-permission>
<!-- 用于访问wifi网络信息,wifi信息会用于进行网络定位-->
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE"></uses-permission>
<!-- 获取运营商信息,用于支持提供运营商信息相关的接口-->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"></uses-permission>
<!-- 这个权限用于获取wifi的获取权限,wifi信息会用来进行网络定位-->
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE"></uses-permission>
<!-- 用于读取手机当前的状态-->
<uses-permission android:name="android.permission.READ_PHONE_STATE"></uses-permission>
<!-- 写入扩展存储,向扩展卡写入数据,用于写入离线定位数据-->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"></uses-permission>
<!-- 访问网络,网络定位需要上网-->
<!-- SD卡读取权限,用户写入离线定位数据-->
<uses-permission android:name="android.permission.MOUNT_UNMOUNT_FILESYSTEMS"></uses-permission>



3. 需要设置 service

也放在 application 里面,后面的版本号与导入的 百度SDK 版本号一致。

1
2
3
4
5
6
7
<service android:name="com.baidu.location.f" android:enabled="true" android:process=":remote">
<intent-filter>
<action android:name="com.baidu.location.service_v4.3" >
</action>
</intent-filter>
</service>

参考

百度官方文档

http://bbs.lbsyun.baidu.com/forum.php?mod=viewthread&tid=10847

http://bbs.csdn.net/topics/390803526

这是友盟 UMSocialSnsService.h 里的一个处理回调的方法

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

/**
 
 Deprecated API
 
 处理app的URL方法
 
 @param url 传入的url
 
 @return wxApiDelegate 实现微信代理对象
 */
+(BOOL)handleOpenURL:(NSURL *)url wxApiDelegate:(id<WXApiDelegate>)wxApiDelegate;

使用发现这个 wxApiDelegate 即使设置了也不会执行 <WXApiDelegate> 里的方法。

所以在微信支付的时候需要进行判断,使用微信SDK的方法设置代理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

/**
 *  AppDelegate 中 支付回调信息处理
 */
+ (BOOL)payCommleteWithOpenURL:(NSURL *)url
{
    GPPayTool * payTool = [GPPayTool shareGPPayTool];

    if ([url.host isEqualToString:@"uppayresult"]) {
        [payTool unionPayOpenURL:url];
    } else if ([url.host isEqualToString:@"safepay"]) {
        [payTool aliPayOpenURL:url];
    } else if ([url.host isEqualToString:@"pay"]) {
       return [WXApi handleOpenURL:url delegate:payTool];
    }else {
       return [UMSocialSnsService handleOpenURL:url];
    }
    return YES;
}

这里我直接判断 host 是否为 pay,可能会跟别的第三方回调重复,保险起见你应该打印一下 这个 url ,可以看到微信支付的 url 格式为 wx000000000://pay/?code=031i50Wd2tYa3R0cL9Ud2bGYVd2i50WE&state=wx000000000 这里是你的微信的 AppId,你应该判断是否等于 ``wx000000000://pay`:

1
[url.absoluteString isEqualToString:@"wx000000000://pay]"

别学我。

原文地址

Apple 在推出 Swift 时就将其冠以先进,安全和高效的新一代编程语言之名。前两点在 Swift 的语法和语言特性中已经表现得淋漓尽致:像是尾随闭包,枚举关联值,可选值和强制的类型安全等都是 Swift 显而易见的优点。但是对于高效一点,就没有那么明显了。在 2014 年 WWDC 大会上 Apple 宣称 Swift 具有超越 Objective-C 的性能,甚至某些情况下可以媲美和超过 C。但是在 Swift 正式发布后,很多开发者发现似乎 Swift 性能并没有像宣传的那样优秀。甚至在 Swift 经过了一年半的演进的今天,稍有不慎就容易掉进语言性能的陷阱中。本文将分析一些使用 Swift 进行 iOS/OS X 开发时性能上的考量和做法,同时,笔者结合自己这一年多来使用 Swift 进行开发的经验,也给出了一些对应办法。

为什么 Swift 的性能值得期待

Swift 具有一门高效语言所需要具备的绝大部分特点。与 Ruby 或者 Python 这样的解释型语言不需要再做什么对比了,相较于其前辈的 Objective-C,Swift 在编译期间就完成了方法的绑定,因此方法调用上不再是类似于 Smalltalk 的消息发送,而是直接获取方法地址并进行调用。虽然 Objective-C 对运行时查找方法的过程进行了缓存和大量的优化,但是不可否认 Swift 的调用方式会更加迅速和高效。

另外,与 Objective-C 不同,Swift 是一门强类型的语言,这意味 Swift 的运行时和代码编译期间的类型是一致的,这样编译器可以得到足够的信息来在生成中间码和机器码时进行优化。虽然都使用 LLVM 工具链进行编译,但是 Swift 的编译过程相比于 Objective-C 要多一个环节 – 生成 Swift 中间代码 (Swift Intermediate Language,SIL)。SIL 中包含有很多根据类型假定的转换,这为之后进一步在更低层级优化提供了良好的基础,分析 SIL 也是我们探索 Swift 性能的有效方法。

最后,Swift 具有良好的内存使用的策略和结构。Swift 标准库中绝大部分类型都是 struct,对值类型的使用范围之广,在近期的编程语言中可谓首屈一指。原本值类型不可变性的特点,往往导致对于值的使用和修改意味着创建新的对象,但是 Swift 巧妙地规避了不必要的值类型复制,而仅只在必要时进行内存分配。这使得 Swift 在享受不可变性带来的便利以及避免不必要的共享状态的同时,还能够保持性能上的优秀。

对性能进行测试

《计算机程序设计艺术》和 TeX 的作者高德纳曾经在论文中说过:

过早的优化是万恶之源。

和很多人理解的不同,这并不是说我们不应该在项目的早期就开始进行优化,而是指我们需要弄清代码中性能真正的问题和希望达到的目标后再开始进行优化。因此,我们需要知道性能问题到底出在哪儿。对程序性能的测试一定是优化的第一步。

在 Cocoa 开发中,对于性能的测试有几种常见的方式。其中最简单是直接通过输出 log 来监测某一段程序运行所消耗的时间。在 Cocoa 中我们可以使用 CACurrentMediaTime 来获取精确的时间。这个方法将会调用 mach 底层的 mach_absolute_time(),它的返回是一个基于 Mach absolute time unit 的数字,我们通过在方法调用前后分别获取两次时刻,并计算它们的间隔,就可以了解方法的执行时间:

1
2
3
4
let start = CACurrentMediaTime()
// ...
let end = CACurrentMediaTime()
print("测量时间:\(end - start)")

为了方便使用,我们还可以将这段代码封装到一个方法中,这样我们就能在项目中需要测试性能的地方方便地使用它了:

1
2
3
4
5
6
7
8
9
func measure(f: ()->()) {
let start = CACurrentMediaTime()
f()
let end = CACurrentMediaTime()
print("测量时间:\(end - start)")
}
measure {
doSomeHeavyWork()
}

CACurrentMediaTime 和 log 的方法适合于我们对既有代码进行探索,另一种有效的方法是使用 Instruments 的 Time Profiler 来在更高层面寻找代码的性能弱点。将程序挂载到 Time Profiler 后,每一个方法调用的耗时都将被记录。

当我们寻找到需要进行优化的代码路径后,为其建立一个单元测试来持续地检测代码的性能是很好的做法。在 Xcode 中默认的测试框架 XCTest 提供了检测并汇报性能的方法:measureBlock。通过将测试的代码块放到 measureBlock 中,Xcode 在测试时就会多次运行这段代码,并统计平均耗时。更方便的是,你可以设定一个基准,Xcode 会记录每次的耗时并在性能没有达到预期时进行提醒。这保证了随着项目开发,关键的代码路径不会发生性能上的退化。

1
2
3
4
5
func testPerformance() {
measureBlock() {
// 需要性能测试的代码
}
}

measure

优化手段,常见误用及对策

多线程、算法及数据结构优化

在确定了需要进行性能改善的代码后,一个最根本的优化方式是在程序设计层面进行改良。在移动客户端,对于影响了 UI 流畅度的代码,我们可以将其放到后台线程进行运行。Grand Central Dispatch (GCD) 或者 NSOperation 可以让我们方便地在不同线程中切换,而不太需要去担心线程调度的问题。一个使用 GCD 将繁重工作放到后台线程,然后在完成后回到主线程操作 UI 的典型例子是这样的:

1
2
3
4
5
6
7
let queue = dispatch_get_global_queue(QOS_CLASS_DEFAULT, 0)
dispatch_async(queue) {
// 运行时间较长的代码,放到后台线程运行
dispatch_async(dispatch_get_main_queue()) {
// 结束后返回主线程操作 UI
}
}

将工作放到其他线程虽然可以避免主线程阻塞,但它并不能减少这些代码实际的执行时间。进一步地,我们可以考虑改进算法和使用的数据结构来提高效率。根据实际项目中遇到的问题的不同,我们会有不同的解决方式,在这篇文章中,我们难以覆盖和深入去分析各种情况,所以这里我们只会提及一些共通的原则。

对于重复的工作,合理地利用缓存的方式可以极大提高效率,这是在优化时可以优先考虑的方式。Cocoa 开发中 NSCache 是专门用来管理缓存的一个类,合理地使用和配置 NSCache 把开发者中从管理缓存存储和失效的工作中解放出来。关于 NSCache 的详细使用方法,可以参看 NSHipster 关于这方面的文章以及 Apple 的相关文档

在程序开发时,数据结构使用上的选择也是重要的一环。Swift 标准库提供了一些很基本的数据结构,比如 ArrayDictionarySet 等。这些数据结构都是配合泛型的,在保证数据类型安全的同时,一般来说也能为我们提供足够的性能。关于这些数据的容器类型方法所对应的复杂度,Apple 都在标准库的文档或者注释中进行了标记。如果标准库所提供的类型和方法无法满足性能上的要求,或者没有符合业务需求的数据结构的话,那么考虑使用自己实现的数据结构也是可选项。

如果项目中有很多数学计算方面的工作导致了效率问题的话,考虑并行计算能极大改善程序性能。iOS 和 OS X 都有针对数学或者图形计算等数字信号处理方面进行了专门优化的框架:Accelerate.framework,利用相关的 API,我们可以轻松快速地完成很多经典的数字或者图像处理问题。因为这个框架只提供一组 C API,所以在 Swift 中直接使用会有一定困难。如果你的项目中要处理的计算相对简单的话,也可以使用 Surge,它是一个基于 Accelerate 框架的 Swift 项目,让我们能在代码里从并行计算中获得难以置信的性能提升。

编译器优化

Swift 编译器十分智能,它能在编译期间帮助我们移除不需要的代码,或者将某些方法进行内联 (inline) 处理。编译器优化的强度可以在编译时通过参数进行控制,Xcode 工程默认情况下有 Debug 和 Release 两种编译配置,在 Debug 模式下,LLVM Code Generation 和 Swift Code Generation 都不开启优化,这能保证编译速度。而在 Release 模式下,LLVM 默认使用 “Fastest, Smallest [-Os]”,Swift Compiler 默认使用 “Fast [-O]”,作为优化级别。我们另外还有几个额外的优化级别可以选择,优化级别越高,编译器对于源码的改动幅度和开启的优化力度也就越大,同时编译期间消耗的时间也就越多。虽然绝大部分情况下没有问题,但是仍然需要当心的是,一些优化等级采用的是激进的优化策略,而禁用了一些检查。这可能在源码很复杂的情况下导致潜在的错误。如果你使用了很高的优化级别,请再三测试 Release 和 Debug 条件下程序运行的逻辑,以防止编译器优化所带来的问题。

值得一提的是,Swift 编译器有一个很有用的优化等级:”Fast, Whole Module Optimization”,也即 -O -whole-module-optimization。在这个优化等级下,Swift 编译器将会同时考虑整个 module 中所有源码的情况,并将那些没有被继承和重载的类型和方法标记为 final,这将尽可能地避免动态派发的调用,或者甚至将方法进行内联处理以加速运行。开启这个额外的优化将会大幅增加编译时间,所以应该只在应用要发布的时候打开这个选项。

虽然现在编译器在进行优化的时候已经足够智能了,但是在面对编写得非常复杂的情况时,很多本应实施的优化可能失效。因此保持代码的整洁、干净和简单,可以让编译器优化良好工作,以得到高效的机器码。

尽量使用 Swift 类型

为了和 Objective-C 协同工作,很多 Swift 标准库类型和对应的 Cocoa 类型是可以隐式的类型转换的,比如 Swift.ArrayNSArraySwift.StringNSString 等。虽然我们不需要在语言层面做类型转换,但是这个过程却不是免费的。在转换次数很多的时候,这往往会成为性能的瓶颈。一个常见的 Swift 和 Objective-C 混用的例子是 JSON 解析。考虑以下代码:

1
2
3
let jsonData: NSData = //...
let jsonObject = try? NSJSONSerialization
.JSONObjectWithData(jsonData, options: []) as? [String: AnyObject]

这是我们日常开发中很常见的代码,使用 NSJSONSerialization 将数据转换为 JSON 对象后,我们得到的是一个 NSObject 对象。在 Swift 中使用时,我们一般会先将其转换为 [String: AnyObject],这个转换在一次性处理成千上万条 JSON 数据时会带来严重的性能退化。Swift 3 中我们可能可以基于 Swift 的 Foundation 框架来解决这个问题,但是现在,如果存在这样的情况,一种处理方式是避免使用 Swift 的字典类型,而使用 NSDictionary。另外,适当地使用 lazy 加载的方法,也是避免一次性进行过多的类型转换的好思路。

尽可能避免混合地使用 Swift 类型和 NSObject 子类,会对性能的提高有所帮助。

避免无意义的 log,保持好的编码习惯

在调试程序时,很多开发者喜欢用输出 log 的方式对代码的运行进行追踪,帮助理解。Swift 编译器并不会帮我们将 print 或者 debugPrint 删去,在最终 app 中它们会把内容输出到终端,造成性能的损失。我们当然可以在发布时用查找的方式将所有这些 log 输出语句删除或者注释掉,但是更好的方法是通过添加条件编译来将这些语句排除在 Release 版本外。在 Xcode 的 Build Setting 中,在 Other Swift flags 的 Debug 栏中加入 -D DEBUG 即可加入一个编译标识。

之后我们就可以通过将 print 或者 debugPrint 包装一下:

1
2
3
4
5
func dPrint(item: Any) {
#if DEBUG
print(item)
#endif
}

这样,在 Release 版本中,dPrint 将会是一个空方法,所有对这个方法的调用都会被编译器剔除掉。需要注意的是,在这种封装下,如果你传入的 items 是一个表达式而不是直接的变量的话,这个表达式还是会被先执行求值的。如果这对性能也产生了可测的影响的话,我们最好用 @autoclosure 修饰参数来重新包装 print。这可以将求值运行推迟到方法内部,这样在 Release 时这个求值也会被一并去掉:

1
2
3
4
5
6
7
func dPrint(@autoclosure item: () -> Any) {
#if DEBUG
print(item())
#endif
}
dPrint(resultFromHeavyWork())
// Release 版本中 resultFromHeavyWork() 不会被执行

小结

Swift 还是一门很新的语言,并且处于高速发展中。因为现在 Swift 只用于 Cocoa 开发,因此它和 Cocoa 框架还有着千丝万缕的联系。很多时候由于这些原因,我们对于 Swift 性能的评估并不公正。这门语言本身设计就是以高性能为考量的,而随着 Swift 的开源和进一步的进化,以及配套框架的全面重写,相信在语言层面上我们能获得更好的性能和编译器的支持。

最好的优化就是不用优化。在软件开发中,保证书写正确简洁的代码,在项目开始阶段就注意可能存在的性能缺陷,将可扩展性的考虑纳入软件构建中,按照实际需求进行优化,不要陷入为了优化而优化的怪圈,这些往往都可以让我们避免额外的优化时间,让我们的工作得更加愉快。

参考

原文地址

自从苹果官方发布了一个 Swift 的 Linux 开源版本之后,服务端 Swift 终于迎来了一个令人激动的前景。我的好奇心终于无法克制,是时候尝试一下服务端 Swift 了!

除了用过几个 Baas 以外,我没有任何后端编程经验,但幸运的是开源社区已经提供了现成的框架。我试了一下 Tanner Nelson 推荐的 Vapor 框架。它的使用非常简单,非常适合我当前的任务,在这篇文档中还会使用到 Heroku。我决定使用 Heroku 的原因是我们的后端团队在使用它,它对于前端来说非常友好。

写到这里的时候,为了解决 Heroku 框架运行中的几个小问题,我专门提交了一个 pull request 。如果代码还没被合并的话,请设置你的包管理器从 这里 下载。

安装

要继续本教程,首先,你需要一个 Heroku 账号 ,并安装好 Swift Development Snapshot 。写到这里的时候,在它的正式版中还未包含 swift 包管理器。因此为了使用这个工具,你必须下载开发版的 snapshot。

开始

我们的目标是创建一个简单的 Swift 服务器并运行在 Heroku 上。这不需要在 linux 环境下进行,就像是你在使用本地服务器。你只消创建一个本地的 Xcode 项目,对项目进行一些设置,然后就可以在 Swift 包管理器 中运行它。整过过程分为 4 个步骤:

####### 将 main.swift 拷贝根目录下的 Sources 目录

swiftService1.png

####### 创建一个 Package.swift 文件

swiftService2.png

####### 将 .build 目录添加到 import paths

要使用自动补全和语法加亮功能,需要将 Swift 包管理器的 build 目录提交到 import paths 中。注意,在 import paths 的 Debug 中设置的 debug 目录,而 Release 项则输入 release 目录。

swiftService3.png

####### 用 toolchain 运行 Xcode

如果你使用 Xcode 7.3,你可以用 Xcode > Toolchains 菜单开启一个Xcode 实例,用于打开 swift snapshot。因为我们不能在 Xcode 中进行编译,我们只能以命令行的方式进行编译。

swiftService4.png

编写服务器

令我高兴的是,为了进行概念验证,我需要编写的代码其实只有寥寥数行。我启动和运行服务器的代码甚至不到 10 行。

swiftService5.png

要启动服务器,只需在终端中输入一句命令,:

swiftService6.png

好了,让我们打开浏览器。我的浏览器安装了 json 插件,你的画面或许会有不同。

swiftService7.png

迁移到云上

服务器在本地顺利运行起来了,但如果放到云端则更好。我迫不及待地想将 App 在云上跑起来。对于我来说这是一个全新的挑战,幸运的是,我得到了 Vincent Toms 的悉心指导。

Heroku 的安装是一件非常愉悦的体验,几分钟后我就创建了一个 Heroku App,接下来我就要上传我的项目了。

####### 出错啦

这只是今天的诸多错误中的一个。我已经预计到事情不可能一帆风顺,因此我查看了 Vapor 的文档,最终发现问题出在所谓的 buildpacks 上。Heorku 提供了一些标准的 buildpacks,但完全没有针对 Swift 的 buildpacks。无奈之下求救于开源社区,刚好看到 Kyle Fuller 的 buildpack 。集成它就简单得多了。

swiftService8.png

用这个 buildpack 启动后,App 成功加载,接下来就是访问它的 URL。

####### 再次出错

swiftService9.png

事情不会那么顺利的,是吧?经过 google 一番,仔细查看了一些例子,我发现我还差一个 Procfile。浏览一下这个文件的内容,你就能明白这个文件是干什么用的了。

swiftService10.png

buildpack 创建了可执行文件,但 Heroku 并不知道。通过 Procfile,我们告诉 Heroku 去运行 SwiftServerIO 可执行文件。上传这个 Procfile。

####### 仍然出错

Heroku 编译的 2 分支仿佛变得无比漫长。我重新打开了浏览器,发现仍然报错。

swiftService11.png

我以为 Heroku 可能还未启动完成(实际不是),因此我又等了一小会,终于发现出错了。可执行文件生成了, process file 也就绪,一定是别的什么地方出问题了。再次 google,一直到我最终发现我需要设置 App 的规模(scale up)。这要使用到 Heroku 的 toolbelt 中的一个简单命令。

heroku ps:scale web=1

Heroku 在免费情况下只有一个 dyno(Heroku 计费单位,10~50 个请求/秒)。但对于我们的简单服务器来说,这也够了。因此,在我们将 scale web 设置为 1 个 dyno 之后,再次用浏览器查看。

成功了!

swiftService13.png

成功了!服务器启动,出现了万能的 hello world!经过一番欢呼雀跃之后,让我们真正来问一声好吧!

####### 响应请求

在 main.swfit 文件中添加一小段代码,让服务器在问好的同时能够因人而异。就微微偷一下懒,新加一个路由,让服务器根据输入输出不同的问候语。

swiftService14.png

一切正常,但根据一般规律,我仍然做好了出错的心理准备。提交修改,push 代码到 Heroku。

####### Say Hello!

swiftService15.png

大约一份多钟的编译之后,在浏览器中访问 URL,服务器返回了问候语。你可以在这里查看效果

接下来是什么?

可以说,服务端 Swift 的今天离不开社区强大支持。对于我来说,能够从云端获取 JSON 是一个令人兴奋的开始,我已经迫不及待地想看看接下来还会发生什么。

当然在此之前,我不得不和我在 Intrepid Pursuits 的同事们一起,继续编写一个有一个 iOS App。如果你想了解我的最新动态,请在访问我的 Github 或者 Twitter

服务端 Swift

附言

这里 下载源代码。

Journal 文件夹中,是 step-by-step 指南。

FFmpeg是一个自由软件,可以运行音频和视频多种格式的录影、转换、流功能,包含了libavcodec——这是一个用于多个项目中音频和视频的解码器库,以及libavformat——一个音频与视频格式转换库。

环境与工具

Mac OS X EI Capitan 10.11.4
Xcode7.3.1
终端

1.安装 yasm

*OS X 下载 Source .tar.gz 即可 *

下载完成后,打开终端,cd 进目录

$ ./configure
$ make
$ make install

安装完成后验证,显示如下表明安装成功

$ yasm --version
yasm 1.3.0
Compiled on May 12 2016.
Copyright (c) 2001-2014 Peter Johnson and other Yasm developers.
Run yasm --license for licensing overview and summary.

2.下载 gas-preprocessor.pl

复制 gas-preprocessor.pl/usr/local/bin(网上很多教程都让放在 /usr/bin 下,但是现在OS X的 usr/bin 目录不可写,所以放在 /usr/local/bin )

修改文件权限 

$ chmod 777 /usr/local/bin/gas-preprocessor.pl

3.下载脚本文件

这个脚本可以一次编译,就生成适合各个框架的静态库。

解压后 cd 进目录 ,执行

$ ./build-ffmpeg.sh

脚本则会自动从github中把ffmpeg源码下到本地并开始编译。 编译结束后,文件目录如下:

SettingFFmpeg

ffmpeg-3.0是源码, FFmpeg-iOS是编译出来的库,里面是我们需要的.a 静态库,一共有7个。 终端输入

$ lipo -info libavcodec.a 
Architectures in the fat file: libavcodec.a are: armv7 i386 x86_64 arm64 

可以看出来静态库支持 armv7 i386 x86_64 arm64  构架

4.把 FFmpeg-iOS 导入工程。

然后在 Build Settings 中找到 Search Paths ,设置 Header Search Pahts 和 Library Search Paths 如下。不然会报 include“libavformat/avformat.h” file not found  错误。

SettingFFmpeg_header

SettingFFmpeg_library

5.在工程中导入其他库文件。

libz.tbdibbz2.tbdlibiconv.tbd,在 3.0以后需要添加另外2个框架 VideoToolbox.frameworkCoreMedia.framework

5.编译

因为这些库是C++,所以需要把一个文件后缀改为 .mm,这样Xcode就会自动打开C++混编,一般会把 AppDelegate.m 改为 AppDelegate.mm

然后随便找个 文件,导入 avcodec.h

1
2
3
4
5
6
#include "avcodec.h"

- (void)viewDidLoad {
  [super viewDidLoad];    
   avcodec_register_all();
}

可以运行起来说明配置成功。

这里放一个Demo

6.可能出现的问题

  • 执行 .sh 文件不成功

    $ ./build-ffmpeg.sh
    -bash: ./build-ffmpeg.sh: No such file or directory

这种类型的错误是找不到 build-ffmpeg.sh 文件,先看看路径对不对,然后再看下里面有没有这个文件,在安装 yasm 的时候我第一次是在 github 上下载的一个,里面没有 .sh 文件,导致无法执行。

  • Xcode编译报错

    Undefined symbols for architecture x86_64: (或者armv7之类的)

    ld: symbol(s) not found for architecture x86_64
    clang: error: linker command failed with exit code 1 (use -v to see invocation)

这种一般都是因为缺少依赖,检查一下上述那3个库和2个框架是否正确添加。

参考资料

WKWebView 是苹果在 iOS 8 中引入的新组件,目的是给出一个新的高性能的 Web View 解决方案,摆脱过去 UIWebView 的老旧笨重特别是内存占用量巨大的问题。

苹果将 UIWebViewDelegateUIWebView 重构成了 14 个类和 3 个协议,引入了不少新的功能和接口,这可以在一定程度上看做苹果对其封锁 Web View 内核的行为作出的补偿:既然你们都说 UIWebView 太渣,那我就造一个不渣的给你们用呗~~ 众所周知,连 Chrome 的 iOS 版用的也是 UIWebView 的内核。

WKWebView 有以下几大主要进步:

  • 浏览器内核渲染进程提取出 App,由系统进行统一管理,这减少了相当一部分的性能损失。
  • js 可以直接使用已经事先注入 js runtime 的 js 接口给 Native 层传值,不必再通过苦逼的 iframe 制造页面刷新再解析自定义协议的奇怪方式。
  • 支持高达 60 fps 的滚动刷新率,内置了手势探测。
开始使用 WKWebView

创建一个名为 BuildYourOwnHybridDevelopmentFramework 的 Single View Application 项目。在 ViewController 顶部引入 WebKit

import WebKit

之后创建一个 WKWebView 类型的成员变量,并对其进行初始化,加入到页面上:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var wk: WKWebView!
override func viewDidLoad() {
super.viewDidLoad()
}
override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
// Dispose of any resources that can be recreated.
}


override func viewDidAppear(animated: Bool) {
super.viewDidAppear(animated)


self.wk = WKWebView(frame: self.view.frame)
self.wk.loadRequest(NSURLRequest(URL: NSURL(string: "http://www.baidu.com/")!))
self.view.addSubview(self.wk)
}

遭遇 BUG

如果你用的是 Xcode 7,这时候你应该得到了一块雪白雪白的屏幕,我们遭遇了 bug。这是因为 iOS 9 SDK 中默认不再支持访问非 HTTPS 的地址,在 info.plist 加入:

1
2
3
4
5
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>

再次运行项目,搞定!

查看效果

WKWebView_0.png

简易错误处理 WKWebView

为了更方便地在开发中调试问题,我们需要处理一下页面加载失败的事件。

加入代理:

1
2
class ViewController: UIViewController, WKNavigationDelegate {
... ...

设置代理对象为 self:

1
self.wk.navigationDelegate = self

处理加载失败事件

我们可以使用以下方式让 View Controller 更优雅,更短小精悍:

1
2
3
4
5
6
7
8
9
10
11
private typealias wkNavigationDelegate = ViewController
extension wkNavigationDelegate {
func webView(webView: WKWebView, didFailNavigation navigation: WKNavigation!,
withError error: NSError) {
NSLog(error.debugDescription)
}
func webView(webView: WKWebView, didFailProvisionalNavigation
navigation: WKNavigation!, withError error: NSError) {
NSLog(error.debugDescription)
}
}

查看效果

我们把 网址从 http://www.baidu.com/ 改成 http://www.baidu/,运行,得到错误:

WKWebView_1.png

搞定!

显示弹窗 WKWebView

在 UIWebView 里,js 的 alert() 弹窗会自动以系统弹窗的形式展示,但是 WKWebview 把这个接口也暴露给了我们,让我们自己 handle js 传来的 alert()。下面我们将自己写代码 handle 住这个事件,并展示为系统弹窗。

加入代理:

1
2
class ViewController: UIViewController, WKNavigationDelegate, WKUIDelegate {
... ...

设置代理对象为 self:

1
self.wk.UIDelegate = self

处理 alert() 事件

1
2
3
4
5
6
7
8
9
10
11
12
13
private typealias wkUIDelegate = ViewController
extension wkUIDelegate {
func webView(webView: WKWebView, runJavaScriptAlertPanelWithMessage message: String,
initiatedByFrame frame: WKFrameInfo, completionHandler: () -> Void) {
let ac = UIAlertController(title: webView.title, message: message, preferredStyle:
UIAlertControllerStyle.Alert)
ac.addAction(UIAlertAction(title: "OK", style: UIAlertActionStyle.Cancel, handler:
{ (aa) -> Void in
completionHandler()
}))
self.presentViewController(ac, animated: true, completion: nil)
}
}

执行 alert()

把网址改为正确的 http://www.baidu.com/,运行项目。然后使用 Safari 自带的 Web View 调试工具执行 alert() 函数:

WKWebView_2.png

查看效果

WKWebView_3.png

OK

转载自YY大神

RunLoop 是 iOS 和 OSX 开发中非常基础的一个概念,这篇文章将从 CFRunLoop 的源码入手,介绍 RunLoop 的概念以及底层实现原理。之后会介绍一下在 iOS 中,苹果是如何利用 RunLoop 实现自动释放池、延迟回调、触摸事件、屏幕刷新等功能的。

RunLoop 的概念

一般来讲,一个线程一次只能执行一个任务,执行完成后线程就会退出。如果我们需要一个机制,让线程能随时处理事件但并不退出,通常的代码逻辑是这样的:

1
2
3
4
5
6
7
function loop() {
initialize();
do {
var message = get_next_message();
process_message(message);
} while (message != quit);
}

这种模型通常被称作 Event Loop。 Event Loop 在很多系统和框架里都有实现,比如 Node.js 的事件处理,比如 Windows 程序的消息循环,再比如 OSX/iOS 里的 RunLoop。实现这种模型的关键点在于:如何管理事件/消息,如何让线程在没有处理消息时休眠以避免资源占用、在有消息到来时立刻被唤醒。

所以,RunLoop 实际上就是一个对象,这个对象管理了其需要处理的事件和消息,并提供了一个入口函数来执行上面 Event Loop 的逻辑。线程执行了这个函数后,就会一直处于这个函数内部 “接受消息->等待->处理” 的循环中,直到这个循环结束(比如传入 quit 的消息),函数返回。

OSX/iOS 系统中,提供了两个这样的对象:NSRunLoop 和 CFRunLoopRef。
CFRunLoopRef 是在 CoreFoundation 框架内的,它提供了纯 C 函数的 API,所有这些 API 都是线程安全的。
NSRunLoop 是基于 CFRunLoopRef 的封装,提供了面向对象的 API,但是这些 API 不是线程安全的。

CFRunLoopRef 的代码是开源的,你可以在这里 http://opensource.apple.com/tarballs/CF/ 下载到整个 CoreFoundation 的源码来查看。

(Update: Swift 开源后,苹果又维护了一个跨平台的 CoreFoundation 版本:https://github.com/apple/swift-corelibs-foundation/,这个版本的源码可能和现有 iOS 系统中的实现略不一样,但更容易编译,而且已经适配了 Linux/Windows。)

RunLoop 与线程的关系

首先,iOS 开发中能遇到两个线程对象: pthread_t 和 NSThread。过去苹果有份文档标明了 NSThread 只是 pthread_t 的封装,但那份文档已经失效了,现在它们也有可能都是直接包装自最底层的 mach thread。苹果并没有提供这两个对象相互转换的接口,但不管怎么样,可以肯定的是 pthread_t 和 NSThread 是一一对应的。比如,你可以通过 pthread_main_thread_np() 或 [NSThread mainThread] 来获取主线程;也可以通过 pthread_self() 或 [NSThread currentThread] 来获取当前线程。CFRunLoop 是基于 pthread 来管理的。

苹果不允许直接创建 RunLoop,它只提供了两个自动获取的函数:CFRunLoopGetMain() 和 CFRunLoopGetCurrent()。 这两个函数内部的逻辑大概是下面这样:

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
/// 全局的Dictionary,key 是 pthread_t, value 是 CFRunLoopRef
static CFMutableDictionaryRef loopsDic;
/// 访问 loopsDic 时的锁
static CFSpinLock_t loopsLock;
/// 获取一个 pthread 对应的 RunLoop。
CFRunLoopRef _CFRunLoopGet(pthread_t thread) {
OSSpinLockLock(&loopsLock);
if (!loopsDic) {
// 第一次进入时,初始化全局Dic,并先为主线程创建一个 RunLoop。
loopsDic = CFDictionaryCreateMutable();
CFRunLoopRef mainLoop = _CFRunLoopCreate();
CFDictionarySetValue(loopsDic, pthread_main_thread_np(), mainLoop);
}
/// 直接从 Dictionary 里获取。
CFRunLoopRef loop = CFDictionaryGetValue(loopsDic, thread));
if (!loop) {
/// 取不到时,创建一个
loop = _CFRunLoopCreate();
CFDictionarySetValue(loopsDic, thread, loop);
/// 注册一个回调,当线程销毁时,顺便也销毁其对应的 RunLoop。
_CFSetTSD(..., thread, loop, __CFFinalizeRunLoop);
}
OSSpinLockUnLock(&loopsLock);
return loop;
}
CFRunLoopRef CFRunLoopGetMain() {
return _CFRunLoopGet(pthread_main_thread_np());
}
CFRunLoopRef CFRunLoopGetCurrent() {
return _CFRunLoopGet(pthread_self());
}

从上面的代码可以看出,线程和 RunLoop 之间是一一对应的,其关系是保存在一个全局的 Dictionary 里。线程刚创建时并没有 RunLoop,如果你不主动获取,那它一直都不会有。RunLoop 的创建是发生在第一次获取时,RunLoop 的销毁是发生在线程结束时。你只能在一个线程的内部获取其 RunLoop(主线程除外)。

RunLoop 对外的接口

在 CoreFoundation 里面关于 RunLoop 有5个类:

CFRunLoopRef
CFRunLoopModeRef
CFRunLoopSourceRef
CFRunLoopTimerRef
CFRunLoopObserverRef

其中 CFRunLoopModeRef 类并没有对外暴露,只是通过 CFRunLoopRef 的接口进行了封装。他们的关系如下:

RunLoop_0.png

一个 RunLoop 包含若干个 Mode,每个 Mode 又包含若干个 Source/Timer/Observer。每次调用 RunLoop 的主函数时,只能指定其中一个 Mode,这个Mode被称作 CurrentMode。如果需要切换 Mode,只能退出 Loop,再重新指定一个 Mode 进入。这样做主要是为了分隔开不同组的 Source/Timer/Observer,让其互不影响。

CFRunLoopSourceRef 是事件产生的地方。Source有两个版本:Source0 和 Source1。
• Source0 只包含了一个回调(函数指针),它并不能主动触发事件。使用时,你需要先调用 CFRunLoopSourceSignal(source),将这个 Source 标记为待处理,然后手动调用 CFRunLoopWakeUp(runloop) 来唤醒 RunLoop,让其处理这个事件。
• Source1 包含了一个 mach_port 和一个回调(函数指针),被用于通过内核和其他线程相互发送消息。这种 Source 能主动唤醒 RunLoop 的线程,其原理在下面会讲到。

CFRunLoopTimerRef 是基于时间的触发器,它和 NSTimer 是toll-free bridged 的,可以混用。其包含一个时间长度和一个回调(函数指针)。当其加入到 RunLoop 时,RunLoop会注册对应的时间点,当时间点到时,RunLoop会被唤醒以执行那个回调。

CFRunLoopObserverRef 是观察者,每个 Observer 都包含了一个回调(函数指针),当 RunLoop 的状态发生变化时,观察者就能通过回调接受到这个变化。可以观测的时间点有以下几个:

1
2
3
4
5
6
7
8
typedef CF_OPTIONS(CFOptionFlags, CFRunLoopActivity) {
kCFRunLoopEntry = (1UL << 0), // 即将进入Loop
kCFRunLoopBeforeTimers = (1UL << 1), // 即将处理 Timer
kCFRunLoopBeforeSources = (1UL << 2), // 即将处理 Source
kCFRunLoopBeforeWaiting = (1UL << 5), // 即将进入休眠
kCFRunLoopAfterWaiting = (1UL << 6), // 刚从休眠中唤醒
kCFRunLoopExit = (1UL << 7), // 即将退出Loop
};

上面的 Source/Timer/Observer 被统称为 mode item,一个 item 可以被同时加入多个 mode。但一个 item 被重复加入同一个 mode 时是不会有效果的。如果一个 mode 中一个 item 都没有,则 RunLoop 会直接退出,不进入循环。

RunLoop 的 Mode

CFRunLoopMode 和 CFRunLoop 的结构大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct __CFRunLoopMode {
CFStringRef _name; // Mode Name, 例如 @"kCFRunLoopDefaultMode"
CFMutableSetRef _sources0; // Set
CFMutableSetRef _sources1; // Set
CFMutableArrayRef _observers; // Array
CFMutableArrayRef _timers; // Array
...
};
struct __CFRunLoop {
CFMutableSetRef _commonModes; // Set
CFMutableSetRef _commonModeItems; // Set<Source/Observer/Timer>
CFRunLoopModeRef _currentMode; // Current Runloop Mode
CFMutableSetRef _modes; // Set
...
};

这里有个概念叫 “CommonModes”:一个 Mode 可以将自己标记为”Common”属性(通过将其 ModeName 添加到 RunLoop 的 “commonModes” 中)。每当 RunLoop 的内容发生变化时,RunLoop 都会自动将 _commonModeItems 里的 Source/Observer/Timer 同步到具有 “Common” 标记的所有Mode里。

应用场景举例:主线程的 RunLoop 里有两个预置的 Mode:kCFRunLoopDefaultMode 和 UITrackingRunLoopMode。这两个 Mode 都已经被标记为”Common”属性。DefaultMode 是 App 平时所处的状态,TrackingRunLoopMode 是追踪 ScrollView 滑动时的状态。当你创建一个 Timer 并加到 DefaultMode 时,Timer 会得到重复回调,但此时滑动一个TableView时,RunLoop 会将 mode 切换为 TrackingRunLoopMode,这时 Timer 就不会被回调,并且也不会影响到滑动操作。

有时你需要一个 Timer,在两个 Mode 中都能得到回调,一种办法就是将这个 Timer 分别加入这两个 Mode。还有一种方式,就是将 Timer 加入到顶层的 RunLoop 的 “commonModeItems” 中。”commonModeItems” 被 RunLoop 自动更新到所有具有”Common”属性的 Mode 里去。

CFRunLoop对外暴露的管理 Mode 接口只有下面2个:

1
2
CFRunLoopAddCommonMode(CFRunLoopRef runloop, CFStringRef modeName);
CFRunLoopRunInMode(CFStringRef modeName, ...);

Mode 暴露的管理 mode item 的接口有下面几个:

1
2
3
4
5
6
CFRunLoopAddSource(CFRunLoopRef rl, CFRunLoopSourceRef source, CFStringRef modeName);
CFRunLoopAddObserver(CFRunLoopRef rl, CFRunLoopObserverRef observer, CFStringRef modeName);
CFRunLoopAddTimer(CFRunLoopRef rl, CFRunLoopTimerRef timer, CFStringRef mode);
CFRunLoopRemoveSource(CFRunLoopRef rl, CFRunLoopSourceRef source, CFStringRef modeName);
CFRunLoopRemoveObserver(CFRunLoopRef rl, CFRunLoopObserverRef observer, CFStringRef modeName);
CFRunLoopRemoveTimer(CFRunLoopRef rl, CFRunLoopTimerRef timer, CFStringRef mode);

你只能通过 mode name 来操作内部的 mode,当你传入一个新的 mode name 但 RunLoop 内部没有对应 mode 时,RunLoop会自动帮你创建对应的 CFRunLoopModeRef。对于一个 RunLoop 来说,其内部的 mode 只能增加不能删除。

苹果公开提供的 Mode 有两个:kCFRunLoopDefaultMode (NSDefaultRunLoopMode) 和 UITrackingRunLoopMode,你可以用这两个 Mode Name 来操作其对应的 Mode。

同时苹果还提供了一个操作 Common 标记的字符串:kCFRunLoopCommonModes (NSRunLoopCommonModes),你可以用这个字符串来操作 Common Items,或标记一个 Mode 为 “Common”。使用时注意区分这个字符串和其他 mode name。

RunLoop 的内部逻辑

根据苹果在文档里的说明,RunLoop 内部的逻辑大致如下:

RunLoop_1.png

其内部代码整理如下 (太长了不想看可以直接跳过去,后面会有说明):

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
88
89
/// 用DefaultMode启动
void CFRunLoopRun(void) {
CFRunLoopRunSpecific(CFRunLoopGetCurrent(), kCFRunLoopDefaultMode, 1.0e10, false);
}
/// 用指定的Mode启动,允许设置RunLoop超时时间
int CFRunLoopRunInMode(CFStringRef modeName, CFTimeInterval seconds, Boolean stopAfterHandle) {
return CFRunLoopRunSpecific(CFRunLoopGetCurrent(), modeName, seconds, returnAfterSourceHandled);
}
/// RunLoop的实现
int CFRunLoopRunSpecific(runloop, modeName, seconds, stopAfterHandle) {
/// 首先根据modeName找到对应mode
CFRunLoopModeRef currentMode = __CFRunLoopFindMode(runloop, modeName, false);
/// 如果mode里没有source/timer/observer, 直接返回。
if (__CFRunLoopModeIsEmpty(currentMode)) return;
/// 1. 通知 Observers: RunLoop 即将进入 loop。
__CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopEntry);
/// 内部函数,进入loop
__CFRunLoopRun(runloop, currentMode, seconds, returnAfterSourceHandled) {
Boolean sourceHandledThisLoop = NO;
int retVal = 0;
do {
/// 2. 通知 Observers: RunLoop 即将触发 Timer 回调。
__CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopBeforeTimers);
/// 3. 通知 Observers: RunLoop 即将触发 Source0 (非port) 回调。
__CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopBeforeSources);
/// 执行被加入的block
__CFRunLoopDoBlocks(runloop, currentMode);
/// 4. RunLoop 触发 Source0 (非port) 回调。
sourceHandledThisLoop = __CFRunLoopDoSources0(runloop, currentMode, stopAfterHandle);
/// 执行被加入的block
__CFRunLoopDoBlocks(runloop, currentMode);
/// 5. 如果有 Source1 (基于port) 处于 ready 状态,直接处理这个 Source1 然后跳转去处理消息。
if (__Source0DidDispatchPortLastTime) {
Boolean hasMsg = __CFRunLoopServiceMachPort(dispatchPort, &msg)
if (hasMsg) goto handle_msg;
}
/// 通知 Observers: RunLoop 的线程即将进入休眠(sleep)。
if (!sourceHandledThisLoop) {
__CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopBeforeWaiting);
}
/// 7. 调用 mach_msg 等待接受 mach_port 的消息。线程将进入休眠, 直到被下面某一个事件唤醒。
/// • 一个基于 port 的Source 的事件。
/// • 一个 Timer 到时间了
/// • RunLoop 自身的超时时间到了
/// • 被其他什么调用者手动唤醒
__CFRunLoopServiceMachPort(waitSet, &msg, sizeof(msg_buffer), &livePort) {
mach_msg(msg, MACH_RCV_MSG, port); // thread wait for receive msg
}
/// 8. 通知 Observers: RunLoop 的线程刚刚被唤醒了。
__CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopAfterWaiting);
/// 收到消息,处理消息。
handle_msg:
/// 9.1 如果一个 Timer 到时间了,触发这个Timer的回调。
if (msg_is_timer) {
__CFRunLoopDoTimers(runloop, currentMode, mach_absolute_time())
}
/// 9.2 如果有dispatch到main_queue的block,执行block。
else if (msg_is_dispatch) {
__CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__(msg);
}
/// 9.3 如果一个 Source1 (基于port) 发出事件了,处理这个事件
else {
CFRunLoopSourceRef source1 = __CFRunLoopModeFindSourceForMachPort(runloop, currentMode, livePort);
sourceHandledThisLoop = __CFRunLoopDoSource1(runloop, currentMode, source1, msg);
if (sourceHandledThisLoop) {
mach_msg(reply, MACH_SEND_MSG, reply);
}
}
/// 执行加入到Loop的block
__CFRunLoopDoBlocks(runloop, currentMode);
if (sourceHandledThisLoop && stopAfterHandle) {
/// 进入loop时参数说处理完事件就返回。
retVal = kCFRunLoopRunHandledSource;
} else if (timeout) {
/// 超出传入参数标记的超时时间了
retVal = kCFRunLoopRunTimedOut;
} else if (__CFRunLoopIsStopped(runloop)) {
/// 被外部调用者强制停止了
retVal = kCFRunLoopRunStopped;
} else if (__CFRunLoopModeIsEmpty(runloop, currentMode)) {
/// source/timer/observer一个都没有了
retVal = kCFRunLoopRunFinished;
}
/// 如果没超时,mode里没空,loop也没被停止,那继续loop。
} while (retVal == 0);
}
/// 10. 通知 Observers: RunLoop 即将退出。
__CFRunLoopDoObservers(rl, currentMode, kCFRunLoopExit);
}

可以看到,实际上 RunLoop 就是这样一个函数,其内部是一个 do-while 循环。当你调用 CFRunLoopRun() 时,线程就会一直停留在这个循环里;直到超时或被手动停止,该函数才会返回。

RunLoop 的底层实现

从上面代码可以看到,RunLoop 的核心是基于 mach port 的,其进入休眠时调用的函数是 mach_msg()。为了解释这个逻辑,下面稍微介绍一下 OSX/iOS 的系统架构。

RunLoop_3.png

苹果官方将整个系统大致划分为上述4个层次:
应用层包括用户能接触到的图形应用,例如 Spotlight、Aqua、SpringBoard 等。
应用框架层即开发人员接触到的 Cocoa 等框架。
核心框架层包括各种核心框架、OpenGL 等内容。
Darwin 即操作系统的核心,包括系统内核、驱动、Shell 等内容,这一层是开源的,其所有源码都可以在 http://opensource.apple.com 里找到。

我们在深入看一下 Darwin 这个核心的架构:

RunLoop_4.png

其中,在硬件层上面的三个组成部分:Mach、BSD、IOKit (还包括一些上面没标注的内容),共同组成了 XNU 内核。
XNU 内核的内环被称作 Mach,其作为一个微内核,仅提供了诸如处理器调度、IPC (进程间通信)等非常少量的基础服务。
BSD 层可以看作围绕 Mach 层的一个外环,其提供了诸如进程管理、文件系统和网络等功能。
IOKit 层是为设备驱动提供了一个面向对象(C++)的一个框架。

Mach 本身提供的 API 非常有限,而且苹果也不鼓励使用 Mach 的 API,但是这些API非常基础,如果没有这些API的话,其他任何工作都无法实施。在 Mach 中,所有的东西都是通过自己的对象实现的,进程、线程和虚拟内存都被称为”对象”。和其他架构不同, Mach 的对象间不能直接调用,只能通过消息传递的方式实现对象间的通信。”消息”是 Mach 中最基础的概念,消息在两个端口 (port) 之间传递,这就是 Mach 的 IPC (进程间通信) 的核心。

Mach 的消息定义是在 <mach/message.h> 头文件的,很简单:

1
2
3
4
5
6
7
8
9
10
11
12
typedef struct {
mach_msg_header_t header;
mach_msg_body_t body;
} mach_msg_base_t;
typedef struct {
mach_msg_bits_t msgh_bits;
mach_msg_size_t msgh_size;
mach_port_t msgh_remote_port;
mach_port_t msgh_local_port;
mach_port_name_t msgh_voucher_port;
mach_msg_id_t msgh_id;
} mach_msg_header_t;

一条 Mach 消息实际上就是一个二进制数据包 (BLOB),其头部定义了当前端口 local_port 和目标端口 remote_port,
发送和接受消息是通过同一个 API 进行的,其 option 标记了消息传递的方向:

1
2
3
4
5
6
7
8
mach_msg_return_t mach_msg(
mach_msg_header_t *msg,
mach_msg_option_t option,
mach_msg_size_t send_size,
mach_msg_size_t rcv_size,
mach_port_name_t rcv_name,
mach_msg_timeout_t timeout,
mach_port_name_t notify);

为了实现消息的发送和接收,mach_msg() 函数实际上是调用了一个 Mach 陷阱 (trap),即函数mach_msg_trap(),陷阱这个概念在 Mach 中等同于系统调用。当你在用户态调用 mach_msg_trap() 时会触发陷阱机制,切换到内核态;内核态中内核实现的 mach_msg() 函数会完成实际的工作,如下图:

RunLoop_5.png

这些概念可以参考维基百科: System_callTrap_(computing)

RunLoop 的核心就是一个 mach_msg() (见上面代码的第7步),RunLoop 调用这个函数去接收消息,如果没有别人发送 port 消息过来,内核会将线程置于等待状态。例如你在模拟器里跑起一个 iOS 的 App,然后在 App 静止时点击暂停,你会看到主线程调用栈是停留在 mach_msg_trap() 这个地方。

关于具体的如何利用 mach port 发送信息,可以看看 NSHipster 这一篇文章,或者这里的中文翻译 。

关于Mach的历史可以看看这篇很有趣的文章:Mac OS X 背后的故事(三)Mach 之父 Avie Tevanian

苹果用 RunLoop 实现的功能

首先我们可以看一下 App 启动后 RunLoop 的状态:

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
88
89
CFRunLoop {
current mode = kCFRunLoopDefaultMode
common modes = {
UITrackingRunLoopMode
kCFRunLoopDefaultMode
}
common mode items = {
// source0 (manual)
CFRunLoopSource {order =-1, {
callout = _UIApplicationHandleEventQueue}}
CFRunLoopSource {order =-1, {
callout = PurpleEventSignalCallback }}
CFRunLoopSource {order = 0, {
callout = FBSSerialQueueRunLoopSourceHandler}}
// source1 (mach port)
CFRunLoopSource {order = 0, {port = 17923}}
CFRunLoopSource {order = 0, {port = 12039}}
CFRunLoopSource {order = 0, {port = 16647}}
CFRunLoopSource {order =-1, {
callout = PurpleEventCallback}}
CFRunLoopSource {order = 0, {port = 2407,
callout = _ZL20notify_port_callbackP12__CFMachPortPvlS1_}}
CFRunLoopSource {order = 0, {port = 1c03,
callout = __IOHIDEventSystemClientAvailabilityCallback}}
CFRunLoopSource {order = 0, {port = 1b03,
callout = __IOHIDEventSystemClientQueueCallback}}
CFRunLoopSource {order = 1, {port = 1903,
callout = __IOMIGMachPortPortCallback}}
// Ovserver
CFRunLoopObserver {order = -2147483647, activities = 0x1, // Entry
callout = _wrapRunLoopWithAutoreleasePoolHandler}
CFRunLoopObserver {order = 0, activities = 0x20, // BeforeWaiting
callout = _UIGestureRecognizerUpdateObserver}
CFRunLoopObserver {order = 1999000, activities = 0xa0, // BeforeWaiting | Exit
callout = _afterCACommitHandler}
CFRunLoopObserver {order = 2000000, activities = 0xa0, // BeforeWaiting | Exit
callout = _ZN2CA11Transaction17observer_callbackEP19__CFRunLoopObservermPv}
CFRunLoopObserver {order = 2147483647, activities = 0xa0, // BeforeWaiting | Exit
callout = _wrapRunLoopWithAutoreleasePoolHandler}
// Timer
CFRunLoopTimer {firing = No, interval = 3.1536e+09, tolerance = 0,
next fire date = 453098071 (-4421.76019 @ 96223387169499),
callout = _ZN2CAL14timer_callbackEP16__CFRunLoopTimerPv (QuartzCore.framework)}
},
modes = {
CFRunLoopMode {
sources0 = { /* same as 'common mode items' */ },
sources1 = { /* same as 'common mode items' */ },
observers = { /* same as 'common mode items' */ },
timers = { /* same as 'common mode items' */ },
},
CFRunLoopMode {
sources0 = { /* same as 'common mode items' */ },
sources1 = { /* same as 'common mode items' */ },
observers = { /* same as 'common mode items' */ },
timers = { /* same as 'common mode items' */ },
},
CFRunLoopMode {
sources0 = {
CFRunLoopSource {order = 0, {
callout = FBSSerialQueueRunLoopSourceHandler}}
},
sources1 = (null),
observers = {
CFRunLoopObserver >{activities = 0xa0, order = 2000000,
callout = _ZN2CA11Transaction17observer_callbackEP19__CFRunLoopObservermPv}
)},
timers = (null),
},
CFRunLoopMode {
sources0 = {
CFRunLoopSource {order = -1, {
callout = PurpleEventSignalCallback}}
},
sources1 = {
CFRunLoopSource {order = -1, {
callout = PurpleEventCallback}}
},
observers = (null),
timers = (null),
},
CFRunLoopMode {
sources0 = (null),
sources1 = (null),
observers = (null),
timers = (null),
}
}
}

可以看到,系统默认注册了5个Mode:

  1. kCFRunLoopDefaultMode: App的默认 Mode,通常主线程是在这个 Mode 下运行的。
  2. UITrackingRunLoopMode: 界面跟踪 Mode,用于 ScrollView 追踪触摸滑动,保证界面滑动时不受其他 Mode 影响。
  3. UIInitializationRunLoopMode: 在刚启动 App 时第进入的第一个 Mode,启动完成后就不再使用。
  4. GSEventReceiveRunLoopMode: 接受系统事件的内部 Mode,通常用不到。
  5. kCFRunLoopCommonModes: 这是一个占位的 Mode,没有实际作用。

你可以在这里看到更多的苹果内部的 Mode,但那些 Mode 在开发中就很难遇到了。

当 RunLoop 进行回调时,一般都是通过一个很长的函数调用出去 (call out), 当你在你的代码中下断点调试时,通常能在调用栈上看到这些函数。下面是这几个函数的整理版本,如果你在调用栈中看到这些长函数名,在这里查找一下就能定位到具体的调用地点了:

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
{
/// 1. 通知Observers,即将进入RunLoop
/// 此处有Observer会创建AutoreleasePool: _objc_autoreleasePoolPush();
__CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__(kCFRunLoopEntry);
do {
/// 2. 通知 Observers: 即将触发 Timer 回调。
__CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__(kCFRunLoopBeforeTimers);
/// 3. 通知 Observers: 即将触发 Source (非基于port的,Source0) 回调。
__CFRUNLOOP_IS_CALLING_OUT_TO_A_BLOCK__(block);
/// 4. 触发 Source0 (非基于port的) 回调。
__CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__(source0);
__CFRUNLOOP_IS_CALLING_OUT_TO_A_BLOCK__(block);
/// 6. 通知Observers,即将进入休眠
/// 此处有Observer释放并新建AutoreleasePool: _objc_autoreleasePoolPop(); _objc_autoreleasePoolPush();
__CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__(kCFRunLoopBeforeWaiting);
/// 7. sleep to wait msg.
mach_msg() -> mach_msg_trap();
/// 8. 通知Observers,线程被唤醒
__CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__(kCFRunLoopAfterWaiting);
/// 9. 如果是被Timer唤醒的,回调Timer
__CFRUNLOOP_IS_CALLING_OUT_TO_A_TIMER_CALLBACK_FUNCTION__(timer);
/// 9. 如果是被dispatch唤醒的,执行所有调用 dispatch_async 等方法放入main queue 的 block
__CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__(dispatched_block);
/// 9. 如果如果Runloop是被 Source1 (基于port的) 的事件唤醒了,处理这个事件
__CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE1_PERFORM_FUNCTION__(source1);
} while (...);
/// 10. 通知Observers,即将退出RunLoop
/// 此处有Observer释放AutoreleasePool: _objc_autoreleasePoolPop();
__CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__(kCFRunLoopExit);
}

AutoreleasePool

App启动后,苹果在主线程 RunLoop 里注册了两个 Observer,其回调都是 _wrapRunLoopWithAutoreleasePoolHandler()。

第一个 Observer 监视的事件是 Entry(即将进入Loop),其回调内会调用 _objc_autoreleasePoolPush() 创建自动释放池。其 order 是-2147483647,优先级最高,保证创建释放池发生在其他所有回调之前。

第二个 Observer 监视了两个事件: BeforeWaiting(准备进入休眠) 时调用_objc_autoreleasePoolPop() 和 _objc_autoreleasePoolPush() 释放旧的池并创建新池;Exit(即将退出Loop) 时调用 _objc_autoreleasePoolPop() 来释放自动释放池。这个 Observer 的 order 是 2147483647,优先级最低,保证其释放池子发生在其他所有回调之后。

在主线程执行的代码,通常是写在诸如事件回调、Timer回调内的。这些回调会被 RunLoop 创建好的 AutoreleasePool 环绕着,所以不会出现内存泄漏,开发者也不必显示创建 Pool 了。

事件响应

苹果注册了一个 Source1 (基于 mach port 的) 用来接收系统事件,其回调函数为 __IOHIDEventSystemClientQueueCallback()。

当一个硬件事件(触摸/锁屏/摇晃等)发生后,首先由 IOKit.framework 生成一个 IOHIDEvent 事件并由 SpringBoard 接收。这个过程的详细情况可以参考这里。SpringBoard 只接收按键(锁屏/静音等),触摸,加速,接近传感器等几种 Event,随后用 mach port 转发给需要的App进程。随后苹果注册的那个 Source1 就会触发回调,并调用 _UIApplicationHandleEventQueue() 进行应用内部的分发。

_UIApplicationHandleEventQueue() 会把 IOHIDEvent 处理并包装成 UIEvent 进行处理或分发,其中包括识别 UIGesture/处理屏幕旋转/发送给 UIWindow 等。通常事件比如 UIButton 点击、touchesBegin/Move/End/Cancel 事件都是在这个回调中完成的。

手势识别

当上面的 _UIApplicationHandleEventQueue() 识别了一个手势时,其首先会调用 Cancel 将当前的 touchesBegin/Move/End 系列回调打断。随后系统将对应的 UIGestureRecognizer 标记为待处理。

苹果注册了一个 Observer 监测 BeforeWaiting (Loop即将进入休眠) 事件,这个Observer的回调函数是 _UIGestureRecognizerUpdateObserver(),其内部会获取所有刚被标记为待处理的 GestureRecognizer,并执行GestureRecognizer的回调。

当有 UIGestureRecognizer 的变化(创建/销毁/状态改变)时,这个回调都会进行相应处理。

界面更新

当在操作 UI 时,比如改变了 Frame、更新了 UIView/CALayer 的层次时,或者手动调用了 UIView/CALayer 的 setNeedsLayout/setNeedsDisplay方法后,这个 UIView/CALayer 就被标记为待处理,并被提交到一个全局的容器去。

苹果注册了一个 Observer 监听 BeforeWaiting(即将进入休眠) 和 Exit (即将退出Loop) 事件,回调去执行一个很长的函数:
_ZN2CA11Transaction17observer_callbackEP19__CFRunLoopObservermPv()。这个函数里会遍历所有待处理的 UIView/CAlayer 以执行实际的绘制和调整,并更新 UI 界面。

这个函数内部的调用栈大概是这样的:

1
2
3
4
5
6
7
8
9
10
11
_ZN2CA11Transaction17observer_callbackEP19__CFRunLoopObservermPv()
QuartzCore:CA::Transaction::observer_callback:
CA::Transaction::commit();
CA::Context::commit_transaction();
CA::Layer::layout_and_display_if_needed();
CA::Layer::layout_if_needed();
[CALayer layoutSublayers];
[UIView layoutSubviews];
CA::Layer::display_if_needed();
[CALayer display];
[UIView drawRect];

定时器

NSTimer 其实就是 CFRunLoopTimerRef,他们之间是 toll-free bridged 的。一个 NSTimer 注册到 RunLoop 后,RunLoop 会为其重复的时间点注册好事件。例如 10:00, 10:10, 10:20 这几个时间点。RunLoop为了节省资源,并不会在非常准确的时间点回调这个Timer。Timer 有个属性叫做 Tolerance (宽容度),标示了当时间点到后,容许有多少最大误差。

如果某个时间点被错过了,例如执行了一个很长的任务,则那个时间点的回调也会跳过去,不会延后执行。就比如等公交,如果 10:10 时我忙着玩手机错过了那个点的公交,那我只能等 10:20 这一趟了。

CADisplayLink 是一个和屏幕刷新率一致的定时器(但实际实现原理更复杂,和 NSTimer 并不一样,其内部实际是操作了一个 Source)。如果在两次屏幕刷新之间执行了一个长任务,那其中就会有一帧被跳过去(和 NSTimer 相似),造成界面卡顿的感觉。在快速滑动TableView时,即使一帧的卡顿也会让用户有所察觉。Facebook 开源的 AsyncDisplayLink 就是为了解决界面卡顿的问题,其内部也用到了 RunLoop,这个稍后我会再单独写一页博客来分析。

PerformSelecter

当调用 NSObject 的 performSelecter:afterDelay: 后,实际上其内部会创建一个 Timer 并添加到当前线程的 RunLoop 中。所以如果当前线程没有 RunLoop,则这个方法会失效。

当调用 performSelector:onThread: 时,实际上其会创建一个 Timer 加到对应的线程去,同样的,如果对应线程没有 RunLoop 该方法也会失效。

关于GCD

实际上 RunLoop 底层也会用到 GCD 的东西,比如 RunLoop 是用 dispatch_source_t 实现的 Timer(评论中有人提醒,NSTimer 是用了 XNU 内核的 mk_timer,我也仔细调试了一下,发现 NSTimer 确实是由 mk_timer 驱动,而非 GCD 驱动的)。但同时 GCD 提供的某些接口也用到了 RunLoop, 例如 dispatch_async()。

当调用 dispatch_async(dispatch_get_main_queue(), block) 时,libDispatch 会向主线程的 RunLoop 发送消息,RunLoop会被唤醒,并从消息中取得这个 block,并在回调 CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE() 里执行这个 block。但这个逻辑仅限于 dispatch 到主线程,dispatch 到其他线程仍然是由 libDispatch 处理的。

关于网络请求

iOS 中,关于网络请求的接口自下至上有如下几层:

1
2
3
4
CFSocket
CFNetwork ->ASIHttpRequest
NSURLConnection ->AFNetworking
NSURLSession ->AFNetworking2, Alamofire
  • CFSocket 是最底层的接口,只负责 socket 通信。
  • CFNetwork 是基于 CFSocket 等接口的上层封装,ASIHttpRequest 工作于这一层。
  • NSURLConnection 是基于 CFNetwork 的更高层的封装,提供面向对象的接口,AFNetworking 工作于这一层。
  • NSURLSession 是 iOS7 中新增的接口,表面上是和 NSURLConnection 并列的,但底层仍然用到了 NSURLConnection 的部分功能 (比如 com.apple.NSURLConnectionLoader 线程),AFNetworking2 和 Alamofire 工作于这一层。

下面主要介绍下 NSURLConnection 的工作过程。

通常使用 NSURLConnection 时,你会传入一个 Delegate,当调用了 [connection start] 后,这个 Delegate 就会不停收到事件回调。实际上,start 这个函数的内部会会获取 CurrentRunLoop,然后在其中的 DefaultMode 添加了4个 Source0 (即需要手动触发的Source)。CFMultiplexerSource 是负责各种 Delegate 回调的,CFHTTPCookieStorage 是处理各种 Cookie 的。

当开始网络传输时,我们可以看到 NSURLConnection 创建了两个新线程:com.apple.NSURLConnectionLoader 和 com.apple.CFSocket.private。其中 CFSocket 线程是处理底层 socket 连接的。NSURLConnectionLoader 这个线程内部会使用 RunLoop 来接收底层 socket 的事件,并通过之前添加的 Source0 通知到上层的 Delegate。

RunLoop_network.png

NSURLConnectionLoader 中的 RunLoop 通过一些基于 mach port 的 Source 接收来自底层 CFSocket 的通知。当收到通知后,其会在合适的时机向 CFMultiplexerSource 等 Source0 发送通知,同时唤醒 Delegate 线程的 RunLoop 来让其处理这些通知。CFMultiplexerSource 会在 Delegate 线程的 RunLoop 对 Delegate 执行实际的回调。

RunLoop 的实际应用举例

AFNetworking

AFURLConnectionOperation 这个类是基于 NSURLConnection 构建的,其希望能在后台线程接收 Delegate 回调。为此 AFNetworking 单独创建了一个线程,并在这个线程中启动了一个 RunLoop:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
+ (void)networkRequestThreadEntryPoint:(id)__unused object {
@autoreleasepool {
[[NSThread currentThread] setName:@"AFNetworking"];
NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
[runLoop addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode];
[runLoop run];
}
}
+ (NSThread *)networkRequestThread {
static NSThread *_networkRequestThread = nil;
static dispatch_once_t oncePredicate;
dispatch_once(&oncePredicate, ^{
_networkRequestThread = [[NSThread alloc] initWithTarget:self
selector:@selector(networkRequestThreadEntryPoint:) object:nil];
[_networkRequestThread start];
});
return _networkRequestThread;
}

RunLoop 启动前内部必须要有至少一个 Timer/Observer/Source,所以 AFNetworking 在 [runLoop run] 之前先创建了一个新的 NSMachPort 添加进去了。通常情况下,调用者需要持有这个 NSMachPort (mach_port) 并在外部线程通过这个 port 发送消息到 loop 内;但此处添加 port 只是为了让 RunLoop 不至于退出,并没有用于实际的发送消息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
- (void)start {
[self.lock lock];
if ([self isCancelled]) {
[self performSelector:@selector(cancelConnection)
onThread:[[self class] networkRequestThread]
withObject:nil
waitUntilDone:NO
modes:[self.runLoopModes allObjects]];
} else if ([self isReady]) {
self.state = AFOperationExecutingState;
[self performSelector:@selector(operationDidStart)
onThread:[[self class] networkRequestThread]
withObject:nil
waitUntilDone:NO
modes:[self.runLoopModes allObjects]];
}
[self.lock unlock];
}

当需要这个后台线程执行任务时,AFNetworking 通过调用 [NSObject performSelector:onThread:..] 将这个任务扔到了后台线程的 RunLoop 中。

AsyncDisplayKit

AsyncDisplayKit 是 Facebook 推出的用于保持界面流畅性的框架,其原理大致如下:

UI 线程中一旦出现繁重的任务就会导致界面卡顿,这类任务通常分为3类:排版,绘制,UI对象操作。

排版通常包括计算视图大小、计算文本高度、重新计算子式图的排版等操作。
绘制一般有文本绘制 (例如 CoreText)、图片绘制 (例如预先解压)、元素绘制 (Quartz)等操作。
UI对象操作通常包括 UIView/CALayer 等 UI 对象的创建、设置属性和销毁。

其中前两类操作可以通过各种方法扔到后台线程执行,而最后一类操作只能在主线程完成,并且有时后面的操作需要依赖前面操作的结果 (例如TextView创建时可能需要提前计算出文本的大小)。ASDK 所做的,就是尽量将能放入后台的任务放入后台,不能的则尽量推迟 (例如视图的创建、属性的调整)。

为此,ASDK 创建了一个名为 ASDisplayNode 的对象,并在内部封装了 UIView/CALayer,它具有和 UIView/CALayer 相似的属性,例如 frame、backgroundColor等。所有这些属性都可以在后台线程更改,开发者可以只通过 Node 来操作其内部的 UIView/CALayer,这样就可以将排版和绘制放入了后台线程。但是无论怎么操作,这些属性总需要在某个时刻同步到主线程的 UIView/CALayer 去。

ASDK 仿照 QuartzCore/UIKit 框架的模式,实现了一套类似的界面更新的机制:即在主线程的 RunLoop 中添加一个 Observer,监听了 kCFRunLoopBeforeWaiting 和 kCFRunLoopExit 事件,在收到回调时,遍历所有之前放入队列的待处理的任务,然后一一执行。
具体的代码可以看这里:_ASAsyncTransactionGroup

转载自YY大神

图片通常是移动端流量耗费最多的部分,并且占据着重要的视觉空间。合理的图片格式选用和优化可以为你节省带宽、提升视觉效果。在这篇文章里我会分析一下目前主流和新兴的几种图片格式的特点、性能分析、参数调优,以及相关开源库的选择。

几种图片格式的简介

JPEG 是目前最常见的图片格式,它诞生于 1992 年,是一个很古老的格式。它只支持有损压缩,其压缩算法可以精确控制压缩比,以图像质量换得存储空间。由于它太过常见,以至于许多移动设备的 CPU 都支持针对它的硬编码与硬解码。

PNG 诞生在 1995 年,比 JPEG 晚几年。它本身的设计目的是替代 GIF 格式,所以它与 GIF 有更多相似的地方。PNG 只支持无损压缩,所以它的压缩比是有上限的。相对于 JPEG 和 GIF 来说,它最大的优势在于支持完整的透明通道。

GIF 诞生于 1987 年,随着初代互联网流行开来。它有很多缺点,比如通常情况下只支持 256 种颜色、透明通道只有 1 bit、文件压缩比不高。它唯一的优势就是支持多帧动画,凭借这个特性,它得以从 Windows 1.0 时代流行至今,而且仍然大受欢迎。

在上面这些图片格式诞生后,也有不少公司或团体尝试对他们进行改进,或者创造其他更加优秀的图片格式,比如 JPEG 小组的 JPEG 2000、微软的 JPEG-XR、Google 的 WebP、个人开发者发布的 BPG、FLIF 等。它们相对于老牌的那几个图片格式来说有了很大的进步,但出于各种各样的原因,只有少数几个格式能够流行开来。下面三种就是目前实力比较强的新兴格式了:

APNG 是 Mozilla 在 2008 年发布的一种图片格式,旨在替换掉画质低劣的 GIF 动画。它实际上只是相当于 PNG 格式的一个扩展,所以 Mozilla 一直想把它合并到 PNG 标准里面去。然而 PNG 开发组并没有接受 APNG 这个扩展,而是一直在推进它自己的 MNG 动图格式。MNG 格式过于复杂以至于并没有什么系统或浏览器支持,而 APNG 格式由于简单容易实现,目前已经渐渐流行开来。Mozilla 自己的 Firefox 首先支持了 APNG,随后苹果的 Safari 也开始有了支持, Chrome 目前也已经尝试开始支持 ,可以说未来前景很好。

WebP 是 Google 在 2010 年发布的图片格式,希望以更高的压缩比替代 JPEG。它用 VP8 视频帧内编码作为其算法基础,取得了不错的压缩效果。它支持有损和无损压缩、支持完整的透明通道、也支持多帧动画,并且没有版权问题,是一种非常理想的图片格式。借由 Google 在网络世界的影响力,WebP 在几年的时间内已经得到了广泛的应用。看看你手机里的 App:微博、微信、QQ、淘宝、网易新闻等等,每个 App 里都有 WebP 的身影。Facebook 则更进一步,用 WebP 来显示聊天界面的贴纸动画。

BPG 是著名程序员 Fabrice Bellard 在去年 (2014年) 发布的一款超高压缩比的图片格式。这个程序员有些人可能感觉面生,但说起他的作品 FFmpeg、QEMU 大家想必是都知道的。BPG 使用 HEVC (即 H.265) 帧内编码作为其算法基础,就这点而言,它毋庸置疑是当下最为先进的图片压缩格式。相对于 JP2、JPEG-XR、WebP 来说,同等体积下 BPG 能提供更高的图像质量。另外,得益于它本身基于视频编码算法的特性,它能以非常小的文件体积保存多帧动画。 Fabrice Bellard 聪明的地方在于,他知道自己一个人无法得到各大浏览器厂商的支持,所以他还特地开发了 Javascript 版的解码器,任何浏览器只要加载了这个 76KB 大小的 JS 文件,就可以直接显示 BPG 格式的图片了。目前阻碍它流行的原因就是 HEVC 的版权问题和它较长的编码解码时间。尽管这个图片格式才刚刚发布一年,但已经有不少厂子开始试用了,比如阿里腾讯

移动端图片类型的支持情况

目前主流的移动端对图片格式的支持情况如何呢?我们分别来看一下 Android 和 iOS 目前的图片编解码架构吧:

mobile_image_arch

Android 的图片编码解码是由 Skia 图形库负责的,Skia 通过挂接第三方开源库实现了常见的图片格式的编解码支持。目前来说,Android 原生支持的格式只有 JPEG、PNG、GIF、BMP 和 WebP (Android 4.0 加入),在上层能直接调用的编码方式也只有 JPEG、PNG、WebP 这三种。目前来说 Android 还不支持直接的动图编解码。

iOS 底层是用 ImageIO.framework 实现的图片编解码。目前 iOS 原生支持的格式有:JPEG、JPEG2000、PNG、GIF、BMP、ICO、TIFF、PICT,自 iOS 8.0 起,ImageIO 又加入了 APNG、SVG、RAW 格式的支持。在上层,开发者可以直接调用 ImageIO 对上面这些图片格式进行编码和解码。对于动图来说,开发者可以解码动画 GIF 和 APNG、可以编码动画 GIF。

两个平台在导入第三方编解码库时,都多少对他们进行了一些修改,比如 Android 对 libjpeg 等进行的调整以更好的控制内存,iOS 对 libpng 进行了修改以支持 APNG,并增加了多线程编解码的特性。除此之外,iOS 专门针对 JPEG 的编解码开发了 AppleJPEG.framework,实现了性能更高的硬编码和硬解码,只有当硬编码解码失败时,libjpeg 才会被用到。

静态图片的编码与解码

由于我目前主要是做 iOS 开发,所以下面的性能评测都是基于 iPhone 的,主要测试代码可以在这里看到。测试素材很少,只有两个:

dribbble512_pngcrush.pnglena512_weibo.jpg

第一张是Dribbble 的 Logo,包含 Alpha 通道,用于测试简单的、图形类的图像。
第二张经典的 Lena 图,用于测试照片类的、具有丰富细节的图像。
每个图像都有 64x64、128x128、256x256、512x512 四种分辨率。
测试素材过少可能导致某些测试不够准确,但作为参考大致是没问题的。

JPEG

目前比较知名的 JPEG 库有以下三个:

libjpeg:开发时间最早,使用最广泛的 JPEG 库。由于 JPEG 标准过于复杂和模糊,并没有其他人去实现,所以这个库是 JPEG 的事实标准。

libjpeg-turbo:一个致力于提升编解码速度的 JPEG 库。它基于 libjpeg 进行了改造,用 SIMD 指令集 (MMX、SSE2、NEON) 重写了部分代码,官网称相对于 libjpeg 有 2 到 4 倍的性能提升。

MozJPEG: 一个致力于提升压缩比的 JPEG 库。它是 Mozilla 在 2014 年发布的基于 libjpeg-turbo 进行改造的库,相对于 libjpeg 有 5% ~ 15% 的压缩比提升,但相应的其编码速度也慢了很多。

除了上面这三个库,苹果自己也开发了一个 AppleJPEG,但并没有开源。其调用了芯片提供的 DSP 硬编码和硬解码的功能。虽然它不如上面这三个库功能完善,但其性能非常高。在我的测试中,其编解码速度通常是 libjpeg-turbo 的 1~2 倍。可惜的是,目前开发者并不能直接访问这个库。

下面是 ImageIO (AppleJPEG/libpng) 在 iPhone 6 上的编解码性能:

jpeg_bench_dribbble.pngjpeg_bench_lena.png

可以看到,JPEG 编码中 quality 越小,图片体积就越小,质量越也差,编码时间也越短。解码时间并没有很大的差距,可能是其大部分时间消耗在了函数调用、硬件调用上。苹果在自己的相册 Demo 中提供的 quality 的默认值是 0.9,在这个值附近,图像质量和体积、编码解码时间之间都能取得不错的平衡。

PNG

相对于 JPEG 来说,PNG 标准更为清晰和简单,因此有很多公司或个人都有自己的 PNG 编码解码实现。但目前使用最广的还是 PNG 官方发布的 libpng 库。iOS 和 Android 底层都是调用这个库实现的 PNG 编解码。

下面是 PNG 在 iPhone 6 上的编解码性能:

jpeg_png_bench.png

可以看到,在编解码图形类型(颜色少、细节少)的图片时,PNG 和 JPEG 差距并不大;但是对于照片类型(颜色和细节丰富)的图片来说,PNG 在文件体积、编解码速度上都差 JPEG 不少了。

和 JPEG 不同,PNG 是无损压缩,其并不能提供压缩比的选项,其压缩比是有上限的。目前网上有很多针对 PNG 进行优化的工具和服务,旨在提升 PNG 的压缩比。下面是常见的几个 PNG 压缩工具的性能对比:

png_tools_bench.png

pngcrush 是 Xcode 自带的 PNG 压缩工具,相对于设计师用 Photoshop 生成的图片来说,它能取得不错的压缩效果。ImageOptim 则更进一步,对每张图用多种缩算法进行比对,选择压缩比更高的结果,进一步缩小了文件体积。TinyPNG.com 相对于其他工具来说,压缩比高得不像话。它启用了类似 GIF 那样的颜色索引表对 PNG 进行压缩,所以会导致颜色丰富的图片丢失掉一部分细节。如果使用 TinyPNG 的话,最好在压缩完成后让设计师看一下颜色效果是否可以接受。

WebP

WebP 标准是 Google 定制的,迄今为止也只有 Google 发布的 libwebp 实现了该的编解码 。 所以这个库也是该格式的事实标准。

WebP 编码主要有几个参数:

lossless: YES:有损编码 NO:无损编码。WebP 主要优势在于有损编码,其无损编码的性能和压缩比表现一般。

quality: [0~100] 图像质量,0表示最差质量,文件体积最小,细节损失严重,100表示最高图像质量,文件体积较大。该参数只针对有损压缩有明显效果。Google 官方的建议是 75,腾讯在对 WebP 评测时给出的建议也是 75。在这个值附近,WebP 能在压缩比、图像质量上取得较好的平衡。

method: [0~6] 压缩比,0表示快速压缩,耗时短,压缩质量一般,6表示极限压缩,耗时长,压缩质量好。该参数也只针对有损压缩有明显效果。调节该参数最高能带来 20% ~ 40% 的更高压缩比,但相应的编码时间会增加 5~20 倍。Google 推荐的值是 4。

对于编码无损图片来说,quality=0, method=03 是相对来说比较合适的参数,能够节省编码时间,同时也有不错的压缩比。无损编码图片,quality=75, method=24 是比较合适的参数,能在编码时间、图片质量、文件体积之间有着不错的平衡。

WebP 解码有三个参数:

use_threads : 是否启用 pthread 多线程解码。该参数只对宽度大于 512 的有损图片起作用。开启后内部会用多线程解码,CPU 占用会更高,解码时间平均能缩短 10%~20%。

bypass_filtering : 是否禁用滤波。该参数只对有损图片起作用,开启后大约能缩短 5%~10% 的解码时间,但会造成一些颜色过渡平滑的区域产生色带(banding)。

no_fancy_upsampling : 是否禁用上采样。该参数只对有损图片起作用。在我的测试中,开启该参数后,解码时间反而会增加 5~25%,同时会造成一些图像细节的丢失,线条边缘会增加杂色,显得不自然。

通常情况下,这三个参数都设为 NO 即可,如果要追求更高的解码速度,则可以尝试开启 use_threads 和 bypass_filtering 这两个参数。而 no_fancy_upsampling 在任何情况下都没必要开启。

由于 WebP 测试数据较多,这里只贴一下 512x512 大小的一部分测试结果,感兴趣的可以看文章结尾处的 Excel 附件。

webp_bench.png

对于简单的图形类型的图像(比如 App 内的各种 UI 素材),WebP 无损压缩的文件体积和解码速度某些情况下已经比 PNG 还要理想了,如果你想要对 App 安装包体积进行优化,可以尝试一下 WebP。

对于复杂的图像(比如照片)来说,WebP 无损编码表现并不好,但有损编码表现却非常棒。相近质量的图片解码速度 WebP 相距 JPEG 也已经相差不大了,而文件压缩比却能提升不少。

BPG

BPG 是目前已知最优秀的有损压缩格式了,它能在相同质量下比 JPEG 减少 50% 的体积。下面是经典的 Lena 图的对比,你也可以在这里看到大量其他图片的 BPG、JPEG、JPEG2000、JPEG-XR、WebP 压缩效果的在线对比,效果非常明显。

bpg_demo.png

BPG 目前只有作者发布的 libbpg 可用。但作者基于 libbpg 编译出了一个 Javascript 解码器,很大的扩展了可用范围。bpg 可以以无损和有损压缩两种方式进行编码,有损压缩时可以用 quality 参数控制压缩比,可选范围为 0~51,数值越大压缩比越高。通常来说,25 附近是一个不错的选择,BPG 官方工具默认值是 28。

libbpg 目前并没有针对 ARM NEON 做优化,所以其在移动端的性能表现一般。下面是 iPhone 6 上的性能测试:

bpg_bench.png

由于 bpg 编码时间太长,我并没有将数据放到表格里。可以看到相同质量下,BPG 的解码速度还是差 JPEG 太多,大约慢了 3~5 倍。目前来说,BPG 适用于那些对流量非常敏感,但对解码时间不敏感的地方。从网上的新闻来看,手机淘宝和手机QQ都已经有所尝试,但不清楚他们是否对 BPG 解码进行了优化。

动态图片的编码与解码

动图在网络上非常受欢迎,它近似视频,但通常实现简单、文件体积小,应用范围非常广泛。动图的始祖是 GIF,它自 Windows 1.0 时代就在互联网上流行开来,直到今天仍然难以被其他格式取代。尽管它非常古老,但其所用的原理和今天几种新兴格式几乎一样。

下面是一张 GIF 格式的 QQ 大表情:

bench_gif_demo.gif

这张表情由 6 幅静态图构成,每幅图片有一定的存活时间,连贯播放就形成了动画:

bench_gif_demo1.png

这几张图中,大部分内容是相近的,为了压缩文件体积,通常动图格式都支持一些特殊的方式对相似图片进行裁剪,只保留前后帧不同的部分:

bench_gif_demo2.png

在解码动图时,解码器通常采用所谓”画布模式”进行渲染。想象一下:播放的区域是一张画布,第一帧播放前先把画布清空,然后完整的绘制上第一帧图;播放第二帧时,不再清空画布,而是只把和第一帧不同的区域覆盖到画布上,就像油画的创作那样。

像这样的第一帧就被称为关键帧(即 I 帧,帧内编码帧),而后续的那些通过补偿计算得到的帧被称为预测编码帧(P帧)。一个压缩的比较好的动图内,通常只有少量的关键帧,而其余都是预测编码帧;一个较差的压缩工具制作的动图内,则基本都是关键帧。不同的动图压缩工具通常能得到不同的结果。

除此之外,动图格式通常有更为详细的参数控制每一帧的绘制过程,下面是 GIF/APNG/WebP 通用的几个参数:

Disposal Method (清除方式)
Do Not Dispose:把当前帧增量绘制到画布上,不清空画布。
Restore to Background:绘制当前帧之前,先把画布清空为默认背景色。
Restore to Previous:绘制下一帧前,把先把画布恢复为当前帧的前一帧

Blend Mode (混合模式)
Blend None: 绘制时,全部通道(包含Alpha通道)都会覆盖到画布,相当于绘制前先清空画布的指定区域。
Blend over:绘制时,Alpha 通道会被合成到画布,即通常情况下两张图片重叠的效果。

上面这些技术,就是常见动图格式的基础了,下面分别介绍一下不同动图格式的特点。

GIF

GIF 缺陷非常明显:它通常只支持 256 色索引颜色,这导致它只能通过抖动、差值等方式模拟较多丰富的颜色;它的 Alpha 通道只有 1 bit,这意味着一个像素只能是完全透明或者完全不透明。

gif_apng_demo.gif

上面这是腾讯博客里的一张演示图,可以看到 GIF 由于 Alpha 通道的问题,产生了严重的”毛边”现象。目前通常的解决方案是在图片的边缘加一圈白边,以减轻这种视觉效果:

gif_wrong_demo.png

可以仔细观察一下 QQ、微信等 App 里面的动画表情,几乎每个表情都被一圈白边所环绕,不得不说是一种很无奈的解决方案。

GIF 的制作工具有很多,但效果好、压缩比高的工具非常少。对于已经制作好的 GIF 来说,用 imagemagick 处理一下可以把文件体积压缩不少。如果需要将视频转为 GIF,Cinemagraph Pro 是个不错的傻瓜化工具。这里有一篇文章介绍如何用 ffmpeg 压缩 GIF,虽然参数调节有点麻烦,但效果非常理想。

下面是没有经过优化的 GIF 和经过 ffmpeg 优化编码的 GIF,可以看到差距非常大。

bbb-trans.gifbbb-nodither.gif

APNG

APNG 目前并没有被 PNG 官方所接受,所以 libpng 并不能直接解码 APNG。但由于 APNG 只是基于 PNG 的一个简单扩展,所以在已经支持 PNG 的平台上,可以很轻松的用少量代码实现 APNG 的编解码。Chromium 为了支持 APNG 播放,只增加了不到 600 行代码 ,我自己也用大概 500 行 C 代码实现了一个简单的 APNG 编解码工具。另外,在支持 canvas 的浏览器上,可以用 apng-canvas 直接显示 APNG 动画。APNG 压缩最好的工具目前是 apngasm,大部分图形化工具比如腾讯的 iSparta 都是基于这个工具开发的。

就目前而言, APNG 是 GIF 最好的替代了:实现简单,可用范围广,压缩比不错,显示效果好。

WebP

WebP 在 2010 年 发布时并没有支持动图。2012 年 libwebp v0.2 的时候,Google 才开始尝试支持动画,但其实现有很多问题,性能也非常差,以至于 Chrome 团队一直都没有接受。直到 2013 年,libwebp v0.4 时,动画格式才稳定下来才被 Chrome 所接受。

WebP 动图实际上是把多个单帧 WebP 数据简单打包到一个文件内,而并不是由单帧 WebP 扩展而来,以至于动图格式并不能向上兼容静态图。如果要支持动图,首先在编译 libwebp 时需要加上 demux 模块,解码 WebP 时需要先用 WebPDemuxer 尝试拆包,之后再把拆出来的单帧用 WebPDecode 解码。为了方便编译,我写了个脚本用于打包 iOS 的静态库,加入了 mux 和 demux 模块。

Google 提供了两个简单的命令行工具用于制作动图:gif2webp 能把 GIF 转换为 WebP, webpmux 能把多个 WebP 图片打包为动态图,并且有着很多参数可以调节。这两个工具对相近帧的压缩并不太理想,以至于有的情况下压缩比还不如 APNG,但除此以外也没有其他什么更好的工具可以用了。

BPG

BPG 本身是基于 HEVC (H.265) 视频编码的,其最开始设计时就考虑到了动图的实现。由于它充分利用了 HEVC 的高压缩比和视频编码的特性,其动图压缩比远超其他格式。这里这里有几张 BPG 动图示例,可以看到相同质量下 BPG 动图只有 APNG/WebP/GIF 几十分之一的大小。

我在这里写了个简单的利用 libbpg 解码动图的方法,如有需要可以参考下。

动图性能对比

我把下面这张 GIF 分别转为 WebP、APNG、BPG 动图,并在 iPhone 6 上对其所有帧进行解码。

gif_ermilio.gif

评测结果如下:

anim_bench.png

APNG 在文件体积上比 GIF 略有优势,解码时间相差不多。WebP 在体积和解码时间上都具有较大的优势。BPG 在体积上优势最大,但解码时间也最长。这么看来,APNG 和 WebP 都是不错的选择,而 BPG 还有待性能优化。

最后做一个小广告:如果你是 iOS 平台的开发者,可以试试我开发的 YYWebImage,它支持 APNG、WebP、GIF 动图的异步加载与播放、编码与解码,支持渐进式图像加载,可以替代 SDWebImage、PINRemoteImage、FLAnimatedImage 等开源库。

评测数据

上面提到的所有评测数据表格:image_benchmark.xlsx 推荐用 Excel 打开查看。