南峰子的技术博客

攀登,一步一个脚印,方能知其乐


  • 首页

  • 知识小集

  • Swift

  • Objective-C

  • Cocoa

  • 翻译

  • 源码分析

  • 杂项

  • 归档

Getting Started With RxSwift and RxCocoa

发表于 2017-01-16   |   分类于 翻译

本文由Ellen Shapiro发表于raywenderlich,原文地址是https://www.raywenderlich.com/138547/getting-started-with-rxswift-and-rxcocoa


代码能按你的意愿执行总是件很棒的事。在程序中改变一些东西,告诉代码,然后它就照做了。嗯,这是坨好代码!

在面向对象时代,大多数程序都像这样运行:你的代码告诉你的程序需要做什么,并且有很多方法来监听变化–但同时你又必须主动告诉系统什么时候发生的变化。

目前为止还是很不错,不过如果变化发生时,代码能自动响应更新,生活是不是会更美好呢?这就是响应式编程的基本思想:你的程序可以对底层数据的变化做出响应,而不需要你直接告诉它。这样,你可以更专注于所需要处理的业务逻辑,而不需要去维护特定的状态。

在Objective-C和Swift都可以实现这种操作,主要是通过Key-value Observation,在Swift中还可以使用setter或didSet方法。不过,有时这些方法并不那么好使。为了避免这些问题,现在市面上已经有一些Objective-C和Swift的框架,来实现响应式编程。

如果你想了解更多的信息,可以看看 ‘ReactiveCocoa vs RxSwift‘ 这篇文章。

今天我们将使用一个很棒的框架,RxSwift和它的小伙伴RxCocoa,来实现一个购买巧克力的App,并从那令人恼火的命令式编程过渡到相当awesome的响应式编程。

RxSwift和RxCocoa是什么?

RxSwift和RxCocoa是ReactiveX工具套件的一部分,这个套件包含了多种编程语言和平台。ReactiveX起源于.Net/C#生态体系,不过现在已经非常受Rubyists、JavaScripers、尤其是Java和Android开发人员的欢迎了。

RxSwift是一个用于与Swift语言交互的框架,而RxCocoa是在响应式编程中让Cocoa APIs更容易使用的一个框架。

ReactiveX框架相当于提供了一个词汇表,以方便在不同的语言中描述相同的任务。这让你可以在多语言切换中,专注于语言本身的语法,而不是把时间浪费在将一个普通任务从一种语言转换为另一种新语言。

Observables和Observers

这里有两个基本的概念:Observable和Observer。

  • Observable是发出变化通知的对象;
  • Observer是订阅Observable的对象,以便在Observable变化时接收通知

可以有多个Observer监听同一个Observable。这意味着当Observable发生变化时,会通知所有相关的Observer。

DisposeBag

RxSwift和RxCocoa还有一个额外的工具来辅助处理ARC和内存管理:即DisposeBag。这是Observer对象的一个虚拟”包”,当它们的父对象被释放时,这个虚拟包会被丢弃。

当带有DisposeBag属性的对象调用deinit()时,虚拟包将被清空,且每一个一次性(disposable)Observer会自动取消订阅它所观察的内容。这允许ARC像通常一样回收内存。

如果没有DisposeBag,会有两种结果:或者Observer会产生一个retain cycle,被无限期的绑定到被观察对象上;或者意外地被释放,导致程序崩溃。

所以要成为一个ARC的良民,记得设置Observable对象时,将它们添加到DisposeBag中。这样,它们才能被很好地清理掉。

开始

让我们去买巧克力吧!本教程的初始工程Chocotastic可以在这里获取。下载zip文件并在Xcode中打开工程。

该项目使用CocoaPods,因此需要打开Chocotastic.xcworkspace文件。

构建并运行程序。你会看到以下效果,其中列出了你可以从欧洲购买的几种巧克力,以及各自的价格:

点击单元格,可以将对应的巧克力添加到你的购物车中:

点击右上角的购物车就可以进入付款或重置购物车的界面:

如果点击Checkout,将显示一个信用卡输入页面:

在本教程的后面,你将使用纯响应式来解决问题。点击Cart按钮将返回购物车清单,点击Reset按钮将返回主界面,并显示空购物车。

起点:非响应式

你现在已经知道了程序将做什么,现在该来看看是怎么做的了。打开ChocolatesOfTheWorldViewController.swift文件,在这里你可以看到一些标准的UITableViewDelegate和UITableViewDataSource方法。

还有一个updateCartButton()方法,它用购物车中现有的巧克力数量来更新购物车按钮。这个方法在两个地方被调用:

  • 在viewWillAppear(_:)中,即视图控制器即将被显示时;
  • 在tableView(_:didSelectRowAt:)中,即新添加一个巧克力到购物车后。

这些都是以命令式方法来修改计数:你必须显示调用方法来更新计数。

目前为止,你必须跟踪你要改变值的位置,不过我们要使用响应式技术来重写这些代码。这样,购物车按钮将自已更新,而不关心计数器在哪和怎样被更新。

RxSwift:让购物车按钮响应

所有引用购物车中的项目的方法都使用ShoppingCart.sharedCart单例。打开ShoppingCart.swift文件,你将看到单例实例上一个变量的标准设置方式:

1
var chocolates = [Chocolate]()

现在,巧克力内容的变更不会被观察到。你可以在它的定义中添加一个didSet闭包,但它只有在整个数组被更新才会被调用,而不是它的元素被更新时。

幸运的是,RxSwift有一个解决方案。使用下面这行代码来替代变量的创建:

1
let chocolates: Variable<[Chocolate]> = Variable([])

这个更改暂时会导致一系列编译错误,不过很快会在下面解决这些问题。

这种语法可能稍微有点难理解,所以下面我们来慢慢了解到底发生了什么。

在这里我们不是将chocolates设置为Chocolate对象的数组,而将其定义为一个RxSwift的Variable对象,其中泛型类型指定为Chocolate数组。

Variable是一个类,所以它使用引用语义–即chocolates引用了一个Variable实例。

Variable对象有一个value属性。这是你的Chocolate对象数组的存储位置。

Variable的魔力来自于它的asObservable()方法。你可以添加一个Observer来观察这个值,而不是每次手动去确认这个值。当值发生变化时,Observer会通知你,以便你对任何更新做出响应。

这个设置有一个缺点,即当你需要访问或更新Chocolates数组中的元素时,你必须使用value属性,而不能直接使用它;这就是编译器提示错误的原因。是时候来解决它们了。

在ShoppingCart.swift中,找到totalCost()并修改下面这行:

1
return chocolates.reduce(0) {

为:

1
return chocolates.value.reduce(0) {

在itemCountString(),修改:

1
guard chocolates.count > 0 else {

为:

1
guard chocolates.value.count > 0 else {

并修改:

1
let setOfChocolates = Set<Chocolate>(chocolates)

为:

1
let setOfChocolates = Set<Chocolate>(chocolates.value)

最后,修改:

1
let count: Int = chocolates.reduce(0) {

为:

1
let count: Int = chocolates.value.reduce(0) {

在CartViewController.swift中,找到reset()并修改:

1
ShoppingCart.sharedCart.chocolates = []

为:

1
ShoppingCart.sharedCart.chocolates.value = []

回到ChocolatesOfTheWorldViewController.swift中,修改updateCartButton()的实现:

1
cartButton.title = "\(ShoppingCart.sharedCart.chocolates.value.count) \u{1f36b}"

及在tableView(_:didSelectRowAt:)中,修改下面这行:

1
ShoppingCart.sharedCart.chocolates.append(chocolate)

为:

1
ShoppingCart.sharedCart.chocolates.value.append(chocolate)

哇!这回Xcode高兴了,不再报错了。同时chocolates也可以被监听了。

打开ChocolatesOfTheWorldViewController.swift文件并添加以下属性:

1
let disposeBag = DisposeBag()

这里创建了一个DisposeBag对象,用于确保设置的Observer在deinit()中被清理掉。

在 //MARK: Rx Setup 注释下面添加以下代码:

1
2
3
4
5
6
7
8
9
10
11
//MARK: Rx Setup
private func setupCartObserver() {
//1
ShoppingCart.sharedCart.chocolates.asObservable()
.subscribe(onNext: { //2
chocolates in
self.cartButton.title = "\(chocolates.count) \u{1f36b}"
})
.addDisposableTo(disposeBag) //3
}

这将设置一个响应式的Observer来自动更新购物车。如你所见,RxSwift大量使用链式函数,这意味着每一个函数都接受前一个函数的结果。

来解释一下发生的事情:

  1. 首先,把购物车的chocolates变量作为一个Observable;
  2. 在这个Observable上调用subscribe(onNext:)方法,以了解Observable的值的变化。subscribe(onNext:)接受一个闭包作为参数,在每次值改变时,会执行这个闭包。闭包的传入参数是Observable的新值。你将会接受到变更通知,直到你取消订阅或者你的订阅被丢弃。从这个方法得到的是一个实现了Disposable的Observer对象;
  3. 将上一步得到的Observer对象添加到disposeBag中,以确保在订阅对象被释放时你的订阅被丢弃。

最后,删除命令式的updateCartButton()方法。这将导致在viewWillAppear(_:)和tableView(_:didSelectRowAt:)中报错。

要修复它们,可以删除整个viewWillAppear(_:)方法(因为这里除了调用super外,调用updateCartButton()方法是它唯一做的事情),并删除tableView(_:didSelectRowAt:)中updateCartButton()方法的调用。

构建并运行。你将看到下面的列表:

但是注意,购物车的按钮只显示了'Item'。当你开始点击列表时,什么都没有发生。这是咋回事?

你创建了一个函数来设置你的Rx Observers,但是现在并没有实际调用它,所以也并没有设置Observers。要解决这个问题,可以在viewDidLoad()里面添加如下代码:

1
setupCartObserver()

编译并运行程序,可以看到下面的列表:

点击单元格,购物车中的数量现在可以自动更新了!

哈哈,现在所有的巧克力都可以添加到购物车了。

RxCocoa: 让TableView响应

现在,你已经使用RxSwift让购物车能响应了,现在将使用RxCocoa让UITableView也能响应。

RxCocoa扩展了UI元素以支持响应式API。这让你可以设置UITableView等视图,而不需要直接重写delegate或data source的方法。

为了演示如何工作,可以删除代码中所有的UITableViewDataSource和UITableViewDelegate协议及所有它们的方法。然后,在viewDidLoad()中删除对tableView.dataSource和tableView.delegate的设置。

编译并运行程序,你可以看到原本让人快乐的小列表没有了,巧克力也没有了:

一点都不好玩。现在来把巧克力找回来吧!

首先,为了获得一个响应式的table view,你需要一些让table view响应的东西。在ChocolatesOfTheWorldViewController.swift文件中,更新europeanChocolates属性,让其作为一个Observable对象:

1
let europeanChocolates = Observable.just(Chocolate.ofEurope)

just(_:)方法表示不会对Observable对象的底层值做任何修改,但你仍然需要以Observable值的方式来访问它。

有时,调用just(_:)可能是过度地使用响应式编程了 – 毕竟,如果一个值从不改变,为什么要使用响应式技术呢?在这个例子中,你将使用它来设置将要改变的单元格的响应,不过经常思考如何使用Rx总是件好事。因为虽然你有一个锤子,但不意味着每一个问题都是一个钉子。

现在你已经让europeanChocolates成为一个Observable,然后添加以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
private func setupCellConfiguration() {
//1
europeanChocolates
.bindTo(tableView
.rx //2
.items(cellIdentifier: ChocolateCell.Identifier,
cellType: ChocolateCell.self)) { // 3
row, chocolate, cell in
cell.configureWithChocolate(chocolate: chocolate) //4
}
.addDisposableTo(disposeBag) //5
}

解释一下:

  1. 调用bindTo(_:)将europeanChocolates observable关联到应该为table view每一行执行的代码;
  2. 调用rx,你可以访问任何类的RxCocoa扩展 – 在这里是UITableView;
  3. 调用Rx的items(cellIdentifier:cellType:)方法,传入单元格标识符及要使用的单元格类型。这让Rx框架可以调用出列方法(dequeuing methods),如果你的table view仍然有原始的代理,这些方法也会被正常调用;
  4. 传入一个为每个单元格执行的闭包。闭包的参数包括行信息、行对应的chocolate及单元格对象,这样配置单元格就非常容易了;
  5. 获取到bindTo(_:)返回的Disposable,然后添加到disposeBag。

通常由tableView(_:numberOfRowsInSection:)和numberOfSections(in:)生成的值,现在基于被观察的数据自动计算。tableView(_:cellForRowAt:)方法被闭包所取代。在viewDidLoad()方法中添加一行来调用新设置的方法:

1
setupCellConfiguration()

编译并运行程序,瞧,巧克力又回来了。

但是,当点击每一个巧克力时,他们并没有被添加到购物车。又是哪不对了?

也木有,之前只是删除了tableView(_:didSelectRowAt:),这样就没办法识别单元格点击操作了。

为了解决这个问题,需要使用RxCocoa提供的另一个UITableView的扩展方法:modelSelected(_:),它返回一个Observable,可以观察模型对象什么时候被选中了。

添加以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private func setupCellTapHandling() {
tableView
.rx
.modelSelected(Chocolate.self) //1
.subscribe(onNext: { //2
chocolate in
ShoppingCart.sharedCart.chocolates.value.append(chocolate) //3
if let selectedRowIndexPath = self.tableView.indexPathForSelectedRow {
self.tableView.deselectRow(at: selectedRowIndexPath, animated: true)
} //4
})
.addDisposableTo(disposeBag) //5
}

再来解释一下:

  1. 调用table view的响应式方法modelSelected(_:),传入Chocolate模型以获取项目的正确类型。这个方法返回一个Observable;
  2. 获取到Observable后,调用subscribe(onNext:)方法,传入一个尾随闭包,在模型被选中时会调用这个闭包;
  3. 在尾随闭包中,将选中的巧克力添加到购物车中;
  4. 同样在闭包中,确保当前被点击的单元格选中状态被取消;
  5. subscribe(onNext:)返回一个Disposable,添加这个Disposable到disposeBag中。

最后,在viewDidLoad()中添加下面这行代码:

1
setupCellTapHandling()

编译并运行,可以看到熟悉的场景:

不过现在你可以往购物车添加巧克力了!

RxSwift和Direct Text Input

RxSwift另一个功能是能够获取并响应用户的直接文本输入(Direct Text Input)。

为了尝试一下响应式处理文本输入,你将在信用卡输入表单中添加一些简单的验证和卡类型检测。

非响应式的信用卡处理是由一串UITextFieldDelegate方法来实现的,通常每个方法包含一串的if/else语句,用于区分哪个text field正在被编辑,应该执行什么操作和逻辑处理。

响应式编程将处理操作和逻辑操作直接连接到每个text field。

在BillingInfoViewController.swift,在类的顶部添加以下代码:

1
private let disposeBag = DisposeBag()

和前面一样,这里定义了一个DisposeBag以确保在类的实例被释放时,你的Observables能被正确处理。

对于信用卡号的输入,一个体验比较好的方式是给用户显示信用卡的类型。

为了实现这一操作,可以在//MARK: - Rx Setup下面添加以下代码:

1
2
3
4
5
6
7
8
9
10
11
//MARK: - Rx Setup
private func setupCardImageDisplay() {
cardType
.asObservable()
.subscribe(onNext: {
cardType in
self.creditCardImageView.image = cardType.image
})
.addDisposableTo(disposeBag)
}

稍后,你将依此根据卡类型的更改来更新卡图片。它为变量的值添加了一个Observer,并附加一个闭包在值改变时执行,同时确保Observer添加到disposeBag中以被正确处理。

现在到了最有趣的部分:文本变更处理。

由于用户可能会快速键入,因此你可能不希望每次按键都去验证。这样会导致昂贵的计算和UI卡顿。

一种更好的方式是限制验证的幅度,即只有一定的时间间隔后再去验证用户的输入,而不是每次改变时都去处理。这样,再快的打字速度也不会阻塞整个程序的运行。

Throttling是RxSwift的一个特性。因为在一些东西改变时,通常有大量的逻辑操作。而使用Throttling特性,不会产生大量的逻辑操作,而是以一个小的合理的幅度去执行。

首先,在BillingInfoViewController中的其它属性声明下面添加以下内容:

1
private let throttleInterval = 0.1

这里以秒为单位为抖动(throttle)幅度定义了一个常量。

然后添加以下代码:

1
2
3
4
5
6
7
8
9
10
11
private func setupTextChangeHandling() {
let creditCardValid = creditCardNumberTextField
.rx
.text //1
.throttle(throttleInterval, scheduler: MainScheduler.instance) //2
.map { self.validate(cardText: $0) } //3
creditCardValid
.subscribe(onNext: { self.creditCardNumberTextField.valid = $0 }) //4
.addDisposableTo(disposeBag) //5
}

如果在设置creditCardValid时得到一个”Generic parameter R could not be inferred“编译错误,通常可以显式的声明它的类型来解决问题,如let creditCardValid: Observable。理论上,编译器能推导出它的类型,但有时候还是需要一些帮助。

代码的描述如下:

  1. text是另一个RxCocoa扩展(在使用之前必须先调用rx),这一次是UITextField的扩展。它将text field的内容作为Observable值返回;
  2. 限制输入,以便设置的验证基于设置的时间间隔才运行。scheduler参数是一个更高级的概念,它绑定到一个线程。因为你需要在主线程上执行,所以使用MainScheduler;
  3. 将被限制的输入应用于validate(cardText:)来转换它,validate(cardText:)由当前类提供。如果输入的卡有效,则观察到的布尔值的最终值为true;
  4. 接受所创建的Observable值并订阅它,根据传入的值来更新text field的验证;
  5. 将生成的Disposable添加到disposeBag。

将以下代码添加到setupTextChangeHandling()方法下面,以创建有效期和卡安全代码(CVV)的Observable变量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
let expirationValid = expirationDateTextField
.rx
.text
.throttle(throttleInterval, scheduler: MainScheduler.instance)
.map { self.validate(expirationDateText: $0) }
expirationValid
.subscribe(onNext: { self.expirationDateTextField.valid = $0 })
.addDisposableTo(disposeBag)
let cvvValid = cvvTextField
.rx
.text
.map { self.validate(cvvText: $0) }
cvvValid
.subscribe(onNext: { self.cvvTextField.valid = $0 })
.addDisposableTo(disposeBag)

现在你已经为三个text field的有效性设置了Observable值,接下来添加以下代码:

1
2
3
4
5
6
7
8
let everythingValid = Observable
.combineLatest(creditCardValid, expirationValid, cvvValid) {
$0 && $1 && $2 //All must be true
}
everythingValid
.bindTo(purchaseButton.rx.enabled)
.addDisposableTo(disposeBag)

这里使用了Observable的combineLatest(_:)方法将前面创建的三个Observable组合成第四个变量,即everythingValid,其值是否为true取决于前面三个输入是否有效。

然后将everythingValid绑定到UIButton的响应扩展的enabled属性上,这样购买按钮的状态就由everythingValid的值来控制了。

如果所有的输入都有效,那么everythingValid的基础值就为true。如果不是,rx.enabled将会导致基础值被应用于购买按钮,该功能仅在信用卡详细信息有效时启用。

现在你已经创建了setup方法,在viewDidLoad()方法中添加以下代码来调用它:

1
2
setupCardImageDisplay()
setupTextChangeHandling()

编译并运行程序。要进入信用卡输入页面,需要选择一个巧克力将其添加到购物车,然后点击购物车按钮以进入购物车界面。只要购物车中至少有一个巧克力,checkout按钮就是可用的:

点击Checkout按钮,将进入信用卡输入页面:

在卡号text field中输入4–你将看到卡图片显示了Visa的图标:

删除4,卡图片将恢复未知状态。输入55,图片将变成MasterCard:

这个应用涵盖了美国的四种主要信用卡(Visa, MasterCard, American Express, Discover)。如果有有其中一种卡,你可以输入卡号来看看图片是否正确以及卡号是否有效。

如果你没有这些信用卡,你可以使用Paypal用于测试其沙盒的测试卡卡号,这些应该可以通过程序的所有本地验证,即使卡号实际上是不可用的。

一旦输入有效的信用卡卡号,同时有效期和cvv也是有效的,那么Buy Chocolate!按钮将被启用:

点击按钮查看购买的内容及如何付款的摘要:

恭喜你!感谢RxSwift和RxCocoa,你可以买到你的巧克力,想要多少要多少,等你的牙医让你离开为止。

下一步做什么

最终的代码可以在这里找到。

如果你想挑战一下,可以尝试添加一些东西,使这个程序更具响应式:

  • 更改CartViewController,以使用响应式table view来显示购物车的内容;
  • 允许用户直接从购物车添加或删除巧克力,并自动更新价格。

现在你已经尝试了Rx编程,以下是一些资源,以帮助你继续你的旅程:

  1. RxSwift Slack
  2. RxSwift’s Getting Started guide
  3. Max Alexander’s talk on Rx at Realm

最后,我们的Marin Todorov有一个不错的博客,里面有他的rx_marin响应式编程。

iOS知识小集 第9期(2016.09.23)

发表于 2016-09-23   |   分类于 知识小集

还有一个星期,一个星期……就是十一长假了,想想还是很激动的。可是我的iPhone 7还是没有着落,哎,想想还是很桑梓啊。什么时候能把这事给办了?

这期换个法吧,无规则有主题,发个关于Instruments的合集。Instruments是我们查找问题和调做强不可缺少的工具,也很强大。所有抽时间把文档撸了一遍,写了几条知识小集,不过还有些没发出来。这期先把之前发的整理整理吧,主要有以下5个问题:

  1. 使用Instruments检测僵尸对象;
  2. Xcode的Debug navigator中打开Instruments;
  3. Instruments无线Profile;
  4. Instruments访问多次运行的跟踪数据
  5. Abandoned Memory和Generational Analysis

使用Instruments检测僵尸对象

Instruments为我们提供了一个检测僵尸对象的工具:Zombies。使用这个工具时,将会自动开启Enable Zombie Objects模式,而不需要我们自己手动去设置。

我们以下图这段简单的代码为例,点击Product->Profile,启动Instrument。

如下图所示,我们可以看到”Zombies”这个工具。基本操作和其它工具一样,启动后点击工具栏上的红色按钮来启动程序。

在程序运行期间,如果定位到僵尸对象,则会弹出一个提示对话框,如下图所示。

我们可以点击对话框右侧的箭头来定位到具体的代码及调用栈,如下图所示。

双击调用栈对应的方法后,还可以查看具体的代码,如下图所示。

实际上,我们用Allocations工具也可以检测僵尸对象,如下图所示。

我们在属性面板中勾选”Enable NSZombie detection”,其效果和单独使用Zombies工具是一样的。

参考

  1. Find Zombies

Xcode的Debug navigator中打开Instruments

Xcode的Debug navigator中提供了几个计量器来帮助我们跟踪程序的性能,包括CPU、内存、电量等。如图1和2所示。

在每个计量器的详情面板中的右上角,都提供了一个Profile in Instruments按钮,如图2所示(Energy Impact除外,其在面板详情中有几个按钮直接打开Instruments指定的模板,如下图所示),这些按钮可以让我们直接跳转到Instruments中。

在点击这些按钮时,会弹出一个提示框,提示“Transfer current debug session?”,下面三个按钮,如下图所示。

Transfer会在程序当前的运行状态中直接切换到Instruments,然后继续跟踪程序的运行状态;而Restart则是关闭当前运行的程序,重新开始一次新的Profile。

不过,这两种情况都会关闭当前的性能分析(profiling),启动Instruments,初始一个新的性能分析。

参考

  1. Instruments User Guide

Instruments无线Profile

Instruments支持无线的Profile,即设备不需要通过Lighting线连接到Mac电脑,即可进行性能分析。这对于需要使用加速器或者外接配件的应用来说非常有用。如下图所示。

不过要使用此功能,需要满足两个条件:设备必须是注册过的开发设备;无线网络必须支持Bonjour和多路广播(multicast)。

当然,还需要做一些基本配置,可参考Instruments User Guide:Target Devices and Processes。

上周五捣鼓了一会,木有成功,后来发现是网络不支持。

Instruments访问多次运行的跟踪数据

Instruments在一次运行期间可以记录App的多次运行记录。以Allocations为例,开启Instruments后,每结束一次Allocations分析,这条分析就会被记录下来,下次再开启分析时,我们仍然可以看到前一次分析的信息,如下图所示。

通过这些记录,我们可以对比每次分析的差别。这样我们就可以边修改程序,边用Instruments来对其进行分析,并通过这种对比来观察修改的效果。

当然,关闭Instruments时,如果不保存信息,这些记录会被清理掉。

参考

  1. Instruments User Guide:Navigate the Timeline Pane

Abandoned Memory和Generational Analysis

说到内存问题,我们更多的会想到内存泄露和野指针,而实际上还有一类看似不是问题的内存问题:Abandoned Memory(被遗弃的内存)。这类内存可能由于某些原因被分配,但并非一直需要,只是可能在程序运行期的某个时间需要,如内存缓存的图片,还有一个比较普遍的东西–单例。

我们可能会为某个模块创建一个单例对象来维护这个模块所需要的数据,但在退出模块后,这个单例对象依然存在。与内存泄露不同,这些对象从技术上讲依然是有效的。但实际上可能在程序后续的运行中不会再被使用。

使用Instruments定位内存问题,内存泄露和野指针的定位相对来说容易些,内存泄露使用Leaks,野指针则可以使用僵尸对象。而Abandoned Memory则相对不那么明显。Abandoned Memory可以采用所谓的Generational Analysis方法来分析,即反复进入退出某一场景,查看内存的分配与释放情况,以定位哪些对象是属于Abandoned Memory的范畴。

在Allocations工具中,有专门的Generational Analysis设置,如下图所示。

我们可以在程序运行时,在进入某个模块前标记一个Generation,这样会生成一个快照。然后进入、退出,再标记一个Generation,如下图所示。

在详情面板中我们可以看到两个Generation间内存的增长情况,其中就可能存在潜在的被遗弃的对象,如下图所示。定位到问题,即可做相应的优化。

参考

  1. Find Abandoned Memory
  2. About Memory Analysis

小结

Instruments是一个强大的分析工具,其基于DTrace,为我们提供了丰富的功能。在实际开发中,采用正确的姿式来使用Instruments,可以帮我们提高程序的性能、稳定性等。相信大家都经常用三件套:Time Profile、Allocations、Leaks。当然,其它还有很多模板,也可以多去试试。


欢迎关注我的微信公众号:iOS知识小集,扫扫左边站点概览里的二维码就OK了,还有微博:@南峰子_老驴。

iOS知识小集 第8期(2016.09.20)

发表于 2016-09-20   |   分类于 知识小集

今年的Apple发布会也开完了,没有什么太出彩的地方。不过广受非议的iPhone 7依然大卖。群里、微信里都是各种讨论外加各种炫,而我只能静静地看着,等着公司的测试机了。

每次都感叹时间过得快,总是有各种事情,这一晃又三个星期了,哎。这期整理了之前的5个问题,无规则无主题,大伙慢慢看:

  1. block未判空导致的EXC_BAD_ACCESS崩溃;
  2. 多Target开发;
  3. dispatch_sync导致死锁;
  4. makeObjectsPerformSelector:;
  5. NSSetUncaughtExceptionHandler

block未判空导致的EXC_BAD_ACCESS崩溃

我们在调用block时,如果这个block为nil,则程序会崩溃,报类似于EXC_BAD_ACCESS(code=1, address=0xc)异常[32位下的结果,如果是64位,则address=0x10]。如下图所示,这个异常表示程序在试图读取内存地址0xc的信息时出错。

在定义一个block时,编译器会在栈上创建一个结构体,类似于图2的结构体。

1
2
3
4
5
6
7
8
struct Block_layout {
void *isa;
int flags;
int reserved;
void (*invoke)(void *, ...);
struct Block_descriptor *descriptor;
/* Imported variables. */
}

block就是指向这个结构体的指针。其中的invoke就是指向具体实现的函数指针。当block被调用时,程序最终会跳转到这个函数指针指向的代码区。而当block为nil时,程序就会试图去读取0xc地址的信息,而这个地址什么都不会有(duff address),于是抛出一个segmentation fault。在32位系统下,之所以是0xc,是因为invoke前面的三个成员变量的大小正好是12。

所以我们在使用block时,应该首先去判断block是否为空。一种比较优雅的写法是:

1
!block ?: block()

参考

  1. Why do nil / NULL blocks cause bus errors when run?

多Target开发

在Xcode中,一个target表示工程中的一个product,target用于组织product所需要的源文件、资源文件、配置信息等。

在一些情况下,我们可以为一个工程设置多个target,如:同时开发Lite版和正式版;开发版本和发布版本需要不同配置;单工程构建多个相似的App等等。如下图所示。

这么做的好处是在共用一份代码的情况下,可以为不同的target配置不同的资源、信息等,如不同的Info.plist, Build Setting, Build Phase配置等,最后得到不同的product。

参考

  1. Xcode Target
  2. 猿题库iOS客户端的技术细节(一):使用多target来构建大量相似App

dispatch_sync导致死锁

dispatch_sync函数用于将一个block提交到队列中同步执行,直到block执行完后,这个函数才会返回。

这里有一个潜在的问题,如果我们在某个串行队列中调用dispatch_sync,并将其block提交到这个串行队列中执行,则会引发死锁。如下代码所示。

1
2
3
4
5
6
7
8
9
10
// 死锁
dispatch_queue_t queue = dispatch_queue_create("com.apple.test", NULL);
dispatch_async(queue, ^{
dispatch_sync(queue, ^{
NSLog(@"B");
});
NSLog(@"A");
});

其实还是很好理解,在com.apple.test这个串行队列中,我们执行一个task A,在这个task A中,我们又向队列提交了一个同步的task B。由于是串行队列,task A在task B之前,所以task B的执行依赖于task A的完成,而task B又包含在task A中,task A的完成依赖于task B的完成。这样就成了一个死锁。

所以,千万不要在主队列中这样调用dispatch_sync,否则会导致主线程卡死。

当然,如果在并行队列中这样使用是没有问题的,如下代码所示,可以正常打印出B,A。

1
2
3
4
5
6
7
8
9
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
dispatch_async(queue, ^{
dispatch_sync(queue, ^{
NSLog(@"B");
});
NSLog(@"A");
});

又或是dispatch_sync的目标队列不是当前队列,如下代码所示,也可以正常打印出B,A。

1
2
3
4
5
6
7
8
9
10
dispatch_queue_t queue1 = dispatch_queue_create("com.apple.test1", NULL);
dispatch_queue_t queue2 = dispatch_queue_create("com.apple.test2", NULL);
dispatch_async(queue1, ^{
dispatch_sync(queue2, ^{
NSLog(@"B");
});
NSLog(@"A");
});

我们在使用dispatch_sync提交task时,可以看到大部分情况下task是在dispatch_sync所在的上下文线程中执行,而不管dispatch_sync指定的队列是什么【串行或并行】,如下代码所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 串行队列
NSLog(@"%@", [NSThread currentThread]); // <NSThread: 0x100303310>{number = 1, name = main}
dispatch_queue_t queue = dispatch_queue_create("com.apple.test", NULL);
dispatch_sync(queue, ^{
NSLog(@"%@", [NSThread currentThread]); // <NSThread: 0x100303310>{number = 1, name = main}
});
// 并行队列
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
NSLog(@"%@", [NSThread currentThread]); // <NSThread: 0x100505ea0>{number = 2, name = (null)}
dispatch_queue_t queue = dispatch_queue_create("com.apple.test", NULL);
dispatch_sync(queue, ^{
NSLog(@"%@", [NSThread currentThread]); // <NSThread: 0x100505ea0>{number = 2, name = (null)}
});
});

官方文档给我们的解释是这么做的目的是为了优化性能:

As an optimization, this function invokes the block on the current thread when possible。

我们需要了解的是队列和线程并不是一回事。我们将任务以block的形式提交到队列,然后由GCD来决定将队列中的block分发到系统管理的线程池中的某个线程中去执行。

参考

  1. dispatch_sync

makeObjectsPerformSelector:

遍历一个数组的方法有几种,for, forin, enumerateObjectsUsingBlock:方法。现在用得比较多的可能是enumerateObjectsUsingBlock:,它能很方便地让我们获取到数组中的元素及对应的索引,然后根据这些信息做一些操作,如下代码所示:

1
2
3
4
5
6
7
8
9
10
11
NSMutableArray *array = [[NSMutableArray alloc] init];
for (NSInteger index = 0; index < 10; index++) {
Test *t = [[Test alloc] init];
t.index = index;
[array addObject:t];
}
[array enumerateObjectsUsingBlock:^(Test * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
[obj test];
}];

不过,如果在循环中,只是想调用元素的某一个方法,则可以考虑使用makeObjectsPerformSelector:或者makeObjectsPerformSelector:withObject:,这两个方法会按元素的顺序向数组中的每个元素发送Selector指定的消息。如下代码所示:

1
2
3
4
5
6
7
8
9
10
NSMutableArray *array = [[NSMutableArray alloc] init];
for (NSInteger index = 0; index < 10; index++) {
Test *t = [[Test alloc] init];
t.index = index;
[array addObject:t];
}
[array makeObjectsPerformSelector:@selector(test)];
[array makeObjectsPerformSelector:@selector(testWithNumber:) withObject:@10];

当然,Selector不能是NULL,否则会抛NSInvalidArgumentException异常。大家如果熟悉runtime的话,应该知道消息机制是如何处理调用不存在方法的。

NSSetUncaughtExceptionHandler

Foundation里面提供了一个NSSetUncaughtExceptionHandler函数,可以设置一个顶层异常处理函数,让我们在程序发生异常并终止前,有最后的机会来捕获并输出异常信息,如下代码所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
void UncaughtExceptionHandler(NSException *exception) {
NSArray *symbols = [exception callStackSymbols];
NSString *reason = [exception reason];
NSString *name = [exception name];
NSLog(@"reason = %@", reason);
NSLog(@"name = %@", name);
NSLog(@"symbols = %@", symbols);
}
@implementation AppDelegate
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
NSSetUncaughtExceptionHandler(&UncaughtExceptionHandler);
return YES;
}
@end

这个函数的参数是一个函数指针,指向的函数其签名是:void NSUncaughtExceptionHandler(NSException *exception)。可以看到这个函数有参数是一个NSException对象,通过这个对象我们就可以获取到异常的信息。假定发生数组越界异常时,会有如下输出。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
2016-09-20 11:55:36.719 Test111[5548:199035] reason = *** -[__NSSingleObjectArrayI objectAtIndex:]: index 10 beyond bounds [0 .. 0]
2016-09-20 11:55:36.720 Test111[5548:199035] name = NSRangeException
2016-09-20 11:55:36.720 Test111[5548:199035] symbols = (
0 CoreFoundation 0x0000000106cef34b __exceptionPreprocess + 171
1 libobjc.A.dylib 0x000000010675021e objc_exception_throw + 48
2 CoreFoundation 0x0000000106d47bdf -[__NSSingleObjectArrayI objectAtIndex:] + 111
3 Test111 0x000000010617d87b -[AppDelegate application:didFinishLaunchingWithOptions:] + 235
4 UIKit 0x000000010710968e -[UIApplication _handleDelegateCallbacksWithOptions:isSuspended:restoreState:] + 290
5 UIKit 0x000000010710b013 -[UIApplication _callInitializationDelegatesForMainScene:transitionContext:] + 4236
6 UIKit 0x00000001071113b9 -[UIApplication _runWithMainScene:transitionContext:completion:] + 1731
7 UIKit 0x000000010710e539 -[UIApplication workspaceDidEndTransaction:] + 188
8 FrontBoardServices 0x000000010a2ee76b __FBSSERIALQUEUE_IS_CALLING_OUT_TO_A_BLOCK__ + 24
9 FrontBoardServices 0x000000010a2ee5e4 -[FBSSerialQueue _performNext] + 189
10 FrontBoardServices 0x000000010a2ee96d -[FBSSerialQueue _performNextFromRunLoopSource] + 45
11 CoreFoundation 0x0000000106c94311 __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__ + 17
12 CoreFoundation 0x0000000106c7959c __CFRunLoopDoSources0 + 556
13 CoreFoundation 0x0000000106c78a86 __CFRunLoopRun + 918
14 CoreFoundation 0x0000000106c78494 CFRunLoopRunSpecific + 420
15 UIKit 0x000000010710cdb6 -[UIApplication _run] + 434
16 UIKit 0x0000000107112f34 UIApplicationMain + 159
17 Test111 0x000000010617db4f main + 111
18 libdyld.dylib 0x000000010928a68d start + 1
19 ??? 0x0000000000000001 0x0 + 1
)

不过这个函数有效范围局限于异常,还有很多错误是无法处理的,如EXC_BAD_ACCESS内存访问错误,这类错误抛出的是Signal,需要专门做Signal处理。

小结

Crash始终是我们开发最大最头疼的问题,总会有各种各样的Crash情况出现。看着Fabric里面长长的Crash列表,总是很伤感的。我们的成长史也是一部和Bug战斗的斗争史,自己写的Bug,熬夜也要把它们搞完。继续战斗吧,Bug君。


欢迎关注我的微信公众号:iOS知识小集,扫扫左边站点概览里的二维码就OK了。对了,还有微博:@南峰子_老驴。

iOS知识小集 第7期(2016.08.31)

发表于 2016-08-31   |   分类于 知识小集

好久没写这个系列了,一看都快一年了,当时说好的呢?嗯,说来总是有各种借口,所以还是不说,直接开始新的一期。之前在微博上发了不少知识小集,到现在应该有50多条了,都是平时开发遇到的一些问题,或者看书,看文档,看博客,看WWDC的一些笔记,分享出来。所以在这偷个懒,做一个合集,每期把微博上的知识小集集中一下,可别吐槽。

本期主要收集以下几个小问题:

  1. UIImageView显示gif图片有两种方式
  2. Objective-C中的BOOL类型
  3. dispatch_once死锁
  4. GNU 复合语句
  5. URL转义

UIImageView显示gif图片有两种方式

UIImageView显示gif图片有两种方式。当然前提都是先将gif中的每一帧取出来放到一个个UIImage对象中,将这些对象放到一个数组中,如下代码所示。

1
2
3
4
5
6
7
8
9
10
11
CGImageSourceRef source = CGImageSourceCreateWithData((__bridge CFDataRef)data, NULL);
size_t count = CGImageSourceGetCount(source);
NSMutableArray *images = [NSMutableArray array];
for (size_t i = 0; i < count; i++) {
CGImageRef image = CGImageSourceCreateImageAtIndex(source, i, NULL);
[images addObject:[UIImage imageWithCGImage:image scale:[UIScreen mainScreen] orientation:UIImageOrientationUp]];
CGImageRelease(image);
}
CFRelease(source)

一种方式是将这些UIImage对象通过UIImage的类方法+animatedImageWithImages:duration:组合成一个UIImage对象,然后赋值给UIImageView对象的image属性。

第二种方式是将UIImage对象的数组赋值给UIImageView对象的animationImages属性,然后调用UIImageView对象的startAnimating方法来启动动画。

当然,两种方式都需要计算duration。

Objective-C中的BOOL类型

Objective-C中的BOOL类型在Watch和64位iOS上的原始类型为bool,而在其它情况下是signed char。我们用@encode去看看BOOL的类型串:

1
2
@encode(BOOL) // 64位iOS系统:"B"
@encode(BOOL) // 32位iOS系统,32/64位OS X:"c"

所有这边有一个问题,下面这段代码中变量b的值在不同环境下,其结果可能是不一样的:

1
2
BOOL a = 100 & 20;
BOOL b = (a == YES);

当BOOL为bool时,b的值为1;而当BOOL为signed char时,b的值为0。所以,如果我们判断一个BOOL值是否为真时,不应该通过if(a == YES)这种方式来判断,要么直接就if (a),要么就if (a != NO)。

dispatch_once死锁

在iOS开发中,我们经常会使用到单例,现在Objective-C中写单例的标配是使用dispatch_once。相信这个函数的意义大家都非常清楚了,就是希望dispatch_once参数中的block在全局只执行一次。这个基本上没什么问题。

不过,今天在工程中看到类似于下面这样的代码。在主线程中调用test()方法,会有什么结果呢?

1
2
3
4
5
6
7
8
9
void test() {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
test();
});
printf("This is a test");
}

死锁。是的,死锁,线程直接卡住了。为什么呢?

我们暂停程序,可以看到程序的调用栈,如下图所示:

image

发现程序是卡在dispatch_once_f中。研究一下dispatch_once_f的实现吧,如下代码所示,会发现一些有意思的东西。

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
void
dispatch_once_f(dispatch_once_t *val, void *ctxt, dispatch_function_t func)
{
struct _dispatch_once_waiter_s * volatile *vval =
(struct _dispatch_once_waiter_s**)val;
struct _dispatch_once_waiter_s dow = { NULL, 0 };
struct _dispatch_once_waiter_s *tail, *tmp;
_dispatch_thread_semaphore_t sema;
if (dispatch_atomic_cmpxchg(vval, NULL, &dow)) {
dispatch_atomic_acquire_barrier();
_dispatch_client_callout(ctxt, func);
dispatch_atomic_maximally_synchronizing_barrier();
//dispatch_atomic_release_barrier(); // assumed contained in above
tmp = dispatch_atomic_xchg(vval, DISPATCH_ONCE_DONE);
tail = &dow;
while (tail != tmp) {
while (!tmp->dow_next) {
_dispatch_hardware_pause();
}
sema = tmp->dow_sema;
tmp = (struct _dispatch_once_waiter_s*)tmp->dow_next;
_dispatch_thread_semaphore_signal(sema);
}
} else {
dow.dow_sema = _dispatch_get_thread_semaphore();
for (;;) {
tmp = *vval;
if (tmp == DISPATCH_ONCE_DONE) {
break;
}
dispatch_atomic_store_barrier();
if (dispatch_atomic_cmpxchg(vval, tmp, &dow)) {
dow.dow_next = tmp;
_dispatch_thread_semaphore_wait(dow.dow_sema);
}
}
_dispatch_put_thread_semaphore(dow.dow_sema);
}
}

简单描述一下吧。onceToken在第一次执行block之前,其值将由NULL变为指向第一个调用者的指针(&dow)。如果在block完成之前,有其它的调用者进来,则会把这些调用者放到一个waiter链表中(走else分支),直到block执行完成。waiter链中的每个调用者都会等待一个信号量(dow.dow_sema)。在block执行完成后,除了将onceToken置为DISPATCH_ONCE_DONE外,还会去遍历waiter链中的所有waiter,抛出相应的信号量,以告知waiter们调用结束。

因此上面的死锁问题就好理解了。递归调用test()时,第二次调用作为一个waiter,在等待block完成,而block的完成依赖于test()的执行完成,这就成了一个死锁。

所以应该避免在dispatch_once做递归调用,不管是直接的还是间接的。

再说回单例,个人看法是单例是个好东西,但应该在适当的场景使用。不能因为简便就滥用。抛开内存问题不说,使用不当的话,单例类迟早会变成一个垃圾场。

参考

  1. Why am I getting deadlock with dispatch_once?
  2. 滥用单例之dispatch_once死锁
  3. libdispatch

GNU 复合语句

我们在看一些第三方的代码时,可能会看到类似于下面的代码。

1
2
3
4
5
6
7
[self.view addSubview:({
UIView *view = [[UIView alloc] initWithFrame:(CGRect){CGPointZero, 100.0f, 100.0f}];
view.backgroundColor = [UIColor blueColor];
view.layer.masksToBounds = YES;
view.layer.cornerRadius = 4.0f;
view;
})];

addSubview的参数放在一个”({})”代码块中,而view的创建及属性设置都是在”({})”完成,代码块最后一句即我们要添加的子view。

这种写法沿用了GNU C的一个特性,即复合语句(compound statement)。即在”({})”代码块中,我们可以放置多个语句,这些语句可以是循环、分支、变量声明、函数调用等。而复合语句的最后一句是一个表达式,其作为整个复合语句的最终值。

在写Objective-C代码时,使用复合语句能让我们的代码变得更优雅,特别是创建并添加一堆子view时,能让我们的代码看上去更整洁。建议经常使用。

参考

  1. Statements and Declarations in Expressions

URL转义

在使用+URLWithString:或-initWithString:来创建一个URL对象时,提供的参数字符串必须符合RFC 2396标准Uniform Resource Identifiers (URI): Generic Syntax。而这两个方法又是根据RFC 1738 Uniform Resource Locators (URL)和1808 Relative Uniform Resource Locators两个标准来解析字符串的。故弄玄虚一下。当然我们不需要去了解所有的细节,简单了解一下就行,可以参考一下阮大侠的这篇关于URL编码。

这里要说明的就是:对于我们而言,如果用带有中文的字符串(如”https://www.baidu.com?q=北京“)去创建一个URL对象的话,返回的是一个nil。

image

我们所需要做的就是对不符合标准的字符串进行转义操作。NSString类提供了两个方法来做这种转义操作,一个是-stringByAddingPercentEscapesUsingEncoding:,不过这个方法在iOS 9.0已被废弃;现在更提倡的是用-stringByAddingPercentEncodingWithAllowedCharacters:方法,这个方法是iOS 7.0后添加的。

小结

知识是一点一点积累的,每天一两点,一段时间后,收获也会很大。知识小集的初衷就是这样。

当然,另一方面也需要系统性地去学习整理一些知识,才能把零零碎碎的东西串起来。


欢迎关注我的微信公众号:iOS知识小集,扫扫左边站点概览里的二维码就OK了。对了,还有微博:@南峰子_老驴。

JavaScriptCore Tutorial for iOS: Getting Started

发表于 2016-08-01   |   分类于 翻译

本文由József Vesza发表于raywenderlich,原文地址是https://www.raywenderlich.com/124075/javascriptcore-tutorial

自从2014年Swift发布以来,其受欢迎程度直线上升:从TIOBE的数据来看,在2016年二月份,它的排名已经上升到第16位。不过,同时我们可以看到排名第9位的是Javascript,这门语言与Swift有许多不同之处:Swift在编译时安全性上做了很多努力,而Javascript是弱类型且动态的。

Swift与Javascript有很多不一样的地方,不过有一件事却将他们紧紧绑在一起:你可以使用他们来创建一个轻量级的iOS应用。

在这篇JavaScriptCore的教程中,你将构建一个用于显示类似web页面的iOS应用,并重用web页面现存的Javascript代码。特别是你将了解以下几点:

  • JavaScriptCore框架的组件
  • 如何在iOS代码中调用Javascript方法
  • 如何在Javascript代码中访问本地代码

注:你不需要有JavaScript的经验。如果这篇JavaScriptCore教程已经激起你学习这门语言的兴趣,那么Mozilla Developer Network对于初学者来说是一个非常棒的资源 - 或者你也可以选择看这里介绍的两本书。

入门

下载这篇教程的初始工程并解压。你可以看到下面的目录结构:

  • Web:包含将被转换为iOS应用的web页面的HTML和CSS文件。
  • Native:iOS工程。文章中所有的修改都是在这里。
  • js:包含用于工程的Javascript代码。

App名叫Showtime,用于搜索iTunes上的付费电影。为了查看它的效果,可以用浏览器打开Web/index.html页面,输入你要的价格,然后按下return按钮。

image

如果想在iOS上测试Showtime,则可以打开Native/Showtime中的工程。编译并运行App来看看效果:

image

正如你所看到的,手机中的App还未准备就绪,不过我们慢慢来补充它。工程已经包含了一些代码;我们会一步一步来完善。这个App的目的是提供一个类似web页面的体验:它将把搜索结果显示在一个Collection View中。

JavaScriptCore是什么?

JavaScriptCore框架提供了访问WebKit的Javascript引擎的机制。最初这个框架只有一些支持Mac系统的C API,但到了iOS 7和OS X 10.9后,它提供了一个更加友好的Objective-C封装。这个框架为Swift/Objective-C和Javascript代码提供了更强大的互通性。

注:React Native演示了JavaScriptCore强大功能。如果你想了解如何使用Javascript构建本地应用,你可以看看本站的Introducing React Native tutorial一文。

在这一节中,你将近距离于窥探一下JavaScriptCore的API。JavaScriptCore包含几个主要的组件:JSVirtualMachine、JSContext和JSValue。下面描述它们是如何整合在一起的。

JSVirtualMachine

JavaScript代码是在由JSVirtualMachine类表示的一个虚拟机上执行的。通常情况下你不需要直接与这个类交互,但有一种情况例外:并发执行JavaScript代码。在一个独立的JSVirtualMachine中,是不可能同一时间执行多个线程的。为了支持并行,你必须使用多个虚拟机。

每一个JSVirtualMachine实例都有自己的堆和自己的垃圾回收器,这意味着你不能在虚拟机之间传递对象。一个虚拟机的垃圾收集器不知道如何去处理另一个堆上的值。

JSContext

一个JSContext对象表示JavaScript代码的执行环境。它对应于一个单一的全局对象;它的web开发环境等同于一个窗口对象。不同于一个虚拟机,你可以在多个上下文之间传递对象(因为它们位于同一虚拟机)。

JSValue

JSValue是我们需要处理的主要数据类型:它可以表示任何可能的Javascript值。一个JSValue被绑定到其存活的JSContext对象中。任何来源于上下文对象的值都是JSValue类型。

下图显示了这几个对象是如何一起工作的:

image

现在你对JavaScriptCore框架有了一个更好的了解了,就让我们来写一些代码吧。

调用Javascript方法

回到Xcode,在project navigator中展开Data group,并打开MovieService.swift。这个类将获取并处理来源于iTunes的电影结果。不过现在它几乎是空的;接下来就由你来提供这些方法的具体实现。

MovieService的工作流程如下:

  • loadMoviesWithLimit(_:onComplete:)将获取电影数据。
  • parseResponse(_:withLimit:)将使用共享的JavaScript代码来处理响应数据。

第一步是获取电影列表。如果你熟悉JavaScript开发,你会知道一般是使用XMLHttpRequest对象来访问网络。这个对象并不是语言本身的一部分,所以你不能将它用于一个iOS App的上下文中。相反,你应该使用本地网络代码。

在MovieService类中,找到loadMoviesWithLimit(_:onComplete:)方法并按以下代码来修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func loadMoviesWithLimit(limit: Double, onComplete complete: [Movie] -> ()) {
guard let url = NSURL(string: movieUrl) else {
print("Invalid url format: \(movieUrl)")
return
}
NSURLSession.sharedSession().dataTaskWithURL(url) { data, _, _ in
guard let data = data,
jsonString = String(data: data, encoding: NSUTF8StringEncoding) else {
print("Error while parsing the response data.")
return
}
let movies = self.parseResponse(jsonString, withLimit:limit)
complete(movies)
}.resume()
}

上面的代码片断使用默认的NSURLSession单例来获取电影列表。在你将响应数据传递给JavaScript代码之前,你需要提供一个执行上下文给响应数据。首先,在MovieService.swift文件顶部,在import UIKit下方添加下面这行代码来导入JavaScriptCore:

1
import JavaScriptCore

然后在MovieService中定义如下属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
lazy var context: JSContext? = {
let context = JSContext()
// 1
guard let
commonJSPath = NSBundle.mainBundle().pathForResource("common", ofType: "js") else {
print("Unable to read resource files.")
return nil
}
// 2
do {
let common = try String(contentsOfFile: commonJSPath, encoding: NSUTF8StringEncoding)
context.evaluateScript(common)
} catch (let error) {
print("Error while processing script file: \(error)")
}
return context
}()

这段代码将上下文定义为一个懒加载的JSContext属性:

  1. 首先,你从应用的bundle中加载common.js文件,这个文件中包含你要访问的JavaScript代码。
  2. 在加载文件后,上下文对象将调用context.evaluateScript(),并将文件内容作为其参数,以此来执行js代码。

现在可以调用Javascript方法了。仍然是在MovieService.swift文件中,找到parseResponse(_:withLimit:)方法,添加以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func parseResponse(response: String, withLimit limit: Double) -> [Movie] {
// 1
guard let context = context else {
print("JSContext not found.")
return []
}
// 2
let parseFunction = context.objectForKeyedSubscript("parseJson")
let parsed = parseFunction.callWithArguments([response]).toArray()
// 3
let filterFunction = context.objectForKeyedSubscript("filterByLimit")
let filtered = filterFunction.callWithArguments([parsed, limit]).toArray()
// 4
return []
}

一步一步来看看这个流程:

  1. 首先,确保上下文对象被正确的初始化。如果在设置的时候有任何错误(如:common.js文件不在bundle中),则返回空数组。
  2. 在上下文对象中查询parseJSON()方法。正如上面所提到的,查询的结果将被封装在一个JSValue对象中。接着,使用callWithArguments(_:)来调用方法,并将一个数组作为参数。最后,将返回的JavaScript值转换为一个数组。
  3. filterByLimit()返回匹配给定价格限制的电影列表。
  4. 现在你已经获取到了电影列表,但仍然忘了一点:filtered常量持有一个JSValue数组,你需要将其映射为本地Movie类型。

注:你可以发现objectForKeyedSubscript()方法的使用有点怪怪的。不幸的是,Swift只能用这种原始的下标方法,而不能将其转换成合适的下标方法。而Objective-C可以使用方括号的下标语法。

暴露本地代码

在JavaScript运行时运行本地代码的一种方式是使用block;它们会被自动桥接到JavaScript方法。这里有个小问题:这个方法只适用于Objective-C的block,而不适用于Swift的闭包。为了暴露一个闭包,你必须执行两个任务:

  • 使用@convention(block)特性来修饰一个闭包,以将它桥接到一个Objective-C block。
  • 在你可以将一个block映射到JavaScript方法调用前,你需要将其转换为一个AnyObject对象。

切换到Movie.swift文件,并添加以下方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
static let movieBuilder: @convention(block) [[String : String]] -> [Movie] = { object in
return object.map { dict in
guard let
title = dict["title"],
price = dict["price"],
imageUrl = dict["imageUrl"] else {
print("unable to parse Movie objects.")
fatalError()
}
return Movie(title: title, price: price, imageUrl: imageUrl)
}
}

这个闭包维护一个JavaScript对象数组(元素为字典类型),然后用它们构造Movie实例。

切换回MovieService.swift文件。在parseResponse(_:withLimit:)方法中,使用以下代码来替换的返回语句:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 1
let builderBlock = unsafeBitCast(Movie.movieBuilder, AnyObject.self)
// 2
context.setObject(builderBlock, forKeyedSubscript: "movieBuilder")
let builder = context.evaluateScript("movieBuilder")
// 3
guard let unwrappedFiltered = filtered,
let movies = builder.callWithArguments([unwrappedFiltered]).toArray() as? [Movie] else {
print("Error while processing movies.")
return []
}
return movies
  1. 你使用Swift的unsafeBitCast(_:_:)方法来将block转换成AnyObject。
  2. 在上下文中调用setObject(_:forKeyedSubscript:),将block加载到JavaScript运行时。然后使用evaluateScript()方法获取JavaScript中block的一个引用。
  3. 最后一步是使用callWithArguments(_:)从JavaScript中调用你的block,这个方法传入一个JSValue对象的数组作为参数。返回值可以被转换成Movie对象的数组。

最后来看看效果!编译并运行。在搜索框中输入一个价格,然后你可以看到将显示一些结果:

image

仅仅几行代码,你就构建了一个本地应用,并使用JavaScript来解析和过滤结果。

使用JSExport协议

在JavaScript使用自定义对象的另一种方式是JSExport协议。你必须创建一个继承自JSExport的协议,并声明想要暴露给Javascript的属性和方法。

对于你暴露的每一个本地类,JavaScriptCore都会在适当的JSContext实例中创建一个原型。JavaScriptCore框架这样做是基于这样一个选择基础:默认情况下,你的类的方法或属性自己并不会暴露给JavaScript。相反,你必须选择暴露谁。JSExport的规则如下:

  • 对于被暴露的实例方法,JavaScriptCore创建一个对应的JavaScript函数作为原型对象的属性。
  • 类的属性将作为原型的访问器属性。
  • 对于类方法,JavaScriptCore将在构造器对象中创建一个JavaScript方法。

为了看看在实践中如何处理,我们切换到Movie.swift中,并在类声明前面定义如下一个新的协议:

1
2
3
4
5
6
7
8
9
import JavaScriptCore
@objc protocol MovieJSExports: JSExport {
var title: String { get set }
var price: String { get set }
var imageUrl: String { get set }
static func movieWithTitle(title: String, price: String, imageUrl: String) -> Movie
}

在这里,你指定所有想要暴露的属性并定义一个类方法来在Javascript中构造Movie对象。后者是必须的,因为JavaScriptCore不桥接初始化方法。

现在来修改Movie以实现JSExport协议。使用以下代码来替换整个类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Movie: NSObject, MovieJSExports {
dynamic var title: String
dynamic var price: String
dynamic var imageUrl: String
init(title: String, price: String, imageUrl: String) {
self.title = title
self.price = price
self.imageUrl = imageUrl
}
class func movieWithTitle(title: String, price: String, imageUrl: String) -> Movie {
return Movie(title: title, price: price, imageUrl: imageUrl)
}
}

类方法只是简单地调用适当的初始化方法。

现在你的类已准备好用于JavaScript了。为了了解你可以怎么转换当前的实现,从Resources group中打开additions.js。它已经包含了以下代码:

1
2
3
4
5
var mapToNative = function(movies) {
return movies.map(function (movie) {
return Movie.movieWithTitlePriceImageUrl(movie.title, movie.price, movie.imageUrl);
});
};

上面的方法从输入数组中获取每一个元素,并使用它来构造一个Movie实例。唯一值得指出的是方法签名是如何改变的:因为JavaScript没有命名参数,所以使用驼峰命名法将额外的参数附加到方法名后面。

打开MovieService.swift,使用以下代码替换懒加载上下文属性的闭包实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
lazy var context: JSContext? = {
let context = JSContext()
guard let
commonJSPath = NSBundle.mainBundle().pathForResource("common", ofType: "js"),
additionsJSPath = NSBundle.mainBundle().pathForResource("additions", ofType: "js") else {
print("Unable to read resource files.")
return nil
}
do {
let common = try String(contentsOfFile: commonJSPath, encoding: NSUTF8StringEncoding)
let additions = try String(contentsOfFile: additionsJSPath, encoding: NSUTF8StringEncoding)
context.setObject(Movie.self, forKeyedSubscript: "Movie")
context.evaluateScript(common)
context.evaluateScript(additions)
} catch (let error) {
print("Error while processing script file: \(error)")
}
return context
}()

没有太大的改变。加载additions.js的内容到上下文。使用JSContext的setObject(_:forKeyedSubscript:)方法,你同样可以让Movie原型在上下文中可用。

还剩下最后一件事:在MovieService.swift中,使用以下代码来替换parseResponse(_:withLimit:)的实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func parseResponse(response: String, withLimit limit: Double) -> [Movie] {
guard let context = context else {
print("JSContext not found.")
return []
}
let parseFunction = context.objectForKeyedSubscript("parseJson")
let parsed = parseFunction.callWithArguments([response]).toArray()
let filterFunction = context.objectForKeyedSubscript("filterByLimit")
let filtered = filterFunction.callWithArguments([parsed, limit]).toArray()
let mapFunction = context.objectForKeyedSubscript("mapToNative")
guard let unwrappedFiltered = filtered,
movies = mapFunction.callWithArguments([unwrappedFiltered]).toArray() as? [Movie] else {
return []
}
return movies
}

代码现在使用Javascript运行时的mapToNative()来创建Movie数组。如果现在编译并运行,你可以看到app仍然按预期的来运行:

image

恭喜你!不仅创建了一个浏览电影的很棒的App,同时也学会了通过重用完全由不同的语言实现的代码来创建它。

下一步去哪里?

你可以在这里下载这篇教程完整的代码。

如果你想学习更多关于JavaScriptCore知识,可以看看WWDC 2013的Session 615

我希望你喜欢这篇JavaScriptCore教程。如果有任何问题或评论,可以参考到下面的讨论中来!


欢迎关注我的微信公众号:iOS知识小集,扫扫左边站点概览里的二维码就OK了。对了,还有微博:@南峰子_老驴。

Perfect smooth scrolling in UITableViews

发表于 2015-12-20   |   分类于 翻译

原文由Alexander Orlov发表于medium,地址为https://medium.com/ios-os-x-development/perfect-smooth-scrolling-in-uitableviews-fd609d5275a5#.so9tpnlk1

这篇文章是前两周@叶孤城 叶大在微信群里面的分享,一直到这两天才翻出来研究。很多实用的东西,不过由于水平有限,有些地方没能翻译好,还请大家指正。

我已经在iOS这个最好的移动平台上有几年的开发经验了。在这期间,我已以接触过很多的iOS应用和iOS工程师。

我们的世界很多好的开发者,但有时我发现他们中的一些人并不是很清楚如何充分利用这个最受欢迎的移动设备的整体潜力,来开发真正平滑的应用。

现在,我将尝试从我的视角,来说明一下为了让UITableView更快更平滑,工程师应该做哪些优化。


文章越往后,难度和深度也会不断增加,所以我将以一些你熟悉的东西来开始。文章后面将会讨论iOS绘画系统和UIKit更深层次的一些东西。

内建方法

我相信大多数阅读这篇文章的人都知道这些方法,但一些人,即便是使用过这些方法,也没有以正确的姿式来使用它们。

✻ ✻ ✻ ✻ ✻

首先是重用cell/header/footer的单个实例,即便是我们需要显示多个。这是优化UIScrollView(UITableView的父类)最明显的方式,UIScrollView是由苹果的工程师提供的。为了正确的使用它,你应该只有cell/header/footer类,一次性初始化它们,并返回给UITableView。

在苹果的开发文档里面已经描述了重用cell的流程,在这就没有必须再重复了。

但重要的事情是:在UITableView的dataSource中实现的tableView:cellForRowAtIndexPath:方法,需要为每个cell调用一次,它应该快速执行。所以你需要尽可能快地返回重用cell实例。

不要在这里去执行数据绑定,因为目前在屏幕上还没有cell。为了执行数据绑定,可以在UITableView的delegate方法tableView:willDisplayCell:forRowAtIndexPath:中进行。这个方法在显示cell之前会被调用。

✻ ✻ ✻ ✻ ✻

第二点也不难理解,但是有一件事需要解释一下。

这个方法对于cell定高的UITableView来说没有意义,但如果由于某些原因需要动态高度的cell的话,这个方法可以很容易地让滑动更流畅。

正如我们所知,UITableView是UIScrollView的子类,而UIScrollView的作用是让用户可以与比屏幕实际尺寸更大的区域交互。任何UIScrollView的实例都使用诸如contentSize、contentOffset和其它许多属性来将正确的区域显示给用户。

但是UITableView的问题在哪?正如所解释的一样,UITableView不会同时维护所有cell的实例。相反,它只需要维护显示给用户的那些cell。

那么,UITableView是如何知道它的contentSize呢?它是通过计算所以cell的高度之和来计算contentSize的值。

UITableView的delegate方法tableView:heightForRowAtIndexPath:会为每个cell调用一次,所以你应该非常快地返回高度值。

很多人会犯一个错误,他们会在布局初始化cell实例并绑定数据后去获取它们的高度。如果你想优化滑动的性能,就不应该以这种方式来计算cell的高度,因为这事难以置信的低效,iOS设备标准的60 FPS将会降低到15-20 FPS,滑动会变得很慢。

如果我们没有一个cell的实例,那如何去计算它的高度呢?这里有一段示例代码,它使用类方法,并基于传入的宽度及显示的数据来计算高度值:

image

可以用以下方式来使用上面这个方法返回高度值给UITableView:

image

你在实现这一切的时候能获得了多少乐趣呢?大多数人会说没有。我没有保证过这事很容易。当然,我们可以构建我们自己的类来手动布局和计算高度,但有时候我们没有足够的时间来做这件事。你可以在Telegram的iOS应用代码中找到这种实现的例子。

从iOS 8开始,我们可以在UITableView的delegate中使用自动高度计算,而不需要实现上面提到的方法。为了实现这一功能,你可能会使用AutoLayout,并将rowHeight变量设置为UITableViewAutomaticDimension。可以在StackOverflow中找到更多详细的信息。

尽管可以使用这些方法,但我强烈建议不要使用它们。另外,我也不建议使用复杂的数学计算来获取cell的高度,如果可能,只使用加、减、乘、除就可以。

但如果是AutoLayout呢?它真的跟我所说的一样慢么?你可能会很惊讶,但这是事实。如果你想让你的App在所有设备上都能平滑的滚动,你就会发现这种方法难以置信的慢。你使用的子视图越多,AutoLayout的效率越低。

AutoLayout相对低效的原因是隐藏在底层的命名为”Cassowary“的约束求解系统。如果布局中子视图越多,那么需要求解的约束也越多,进而返回cell给UITableView所花的时间也越多。

哪一个更快呢:使用少量的值来执行基本的数学计算,还是找一个求解大量线性等式或不等式的系统么?现在想像一下,用户想要快速地滑动,每个cell的自动布局也执行着疯狂的计算。

✻ ✻ ✻ ✻ ✻

使用内建方法优化UITableView的正确方法是:

  • 重用cell实例:对于特殊类型的cell,你应该只有一个实例,而没有更多。
  • 不要在cellForRowAtIndexPath:方法中绑定数据,因为在此时cell还没有显示。可以使用UITableView的delegate中的tableView:willDisplayCell:forRowAtIndexPath:方法。
  • 快速计算cell高度。对于工程师来说这是常规工作,但你将会为优化复杂cell的平滑滑动所付出的耐心而获取回报。

我们需要更深一步

当然,上面提到的这些点不足以实现真正的平滑滚动,特别是当你需要实现一些复杂的cell(如有大量的渐变、视图、交互元素、一些修饰元素等等)时,这变得尤其明显。

这种情况下,UITableView很容易变得缓慢,即便是做了上面所有的事情。UITableViewCell中的视图越多,滑动时FPS越低。但在使用了手动布局和优化了高度计算后,问题就不在布局了,而在渲染了。

✻ ✻ ✻ ✻ ✻

让我们把关注点放在UIView的opaque属性上。文档中说它用于辅助绘图系统定义UIView是否透明。如果不透明,则绘图系统在渲染视图时可以做一些优化,以提高性能。

我们需要性能,或者不是?用户可能快速地滑动table,如使用scrollsToTop特性,但他们可能没有最新的iPhone,所以cell必须快速地被渲染。比通常的视图更快。

渲染最慢的操作之一是混合(blending)。混合操作由GPU来执行,因为这个硬件就是用来做混合操作的(当然不只是混合)。

你可能已经猜到,提高性能的方法是减少混合操作的次数。但在此之前,我们需要找到它。让我们来试试。

在iOS模拟器上运行App,在模拟器的菜单中选择’Debug‘,然后选中’Color Blended Layers‘。然后iOS模拟器就会将全部区域显示为两种颜色:绿色和红色。

绿色区域没有混合,但红色区域表示有混合操作。

image

image

正如你所看到的一样,在cell中至少有两处执行了混合操作,但你可能看不出差别来(这个混合操作是不必要的)。

每种情况都应该仔细研究,不同的情况需要使用不同的方法来避免混合。在我这里,我需要做的只是设置backgroundColor来实现非透明。

但有时候可能更复杂。看看这个:我们有一个渐变,但是没有混合。

image

image

如果想要使用CAGradientLayer来实现这个效果,你将会很失望:在iPhone 6中FPS将会降到25-30,快速滑动变得不可能。

这确实发生了,因为我们混合了两个不同层的内容:UILabel的CATextLayer和我们的CAGradientLayer。

如果能正确地利用了CPU和GPU资源,它们将会均匀地负载,FPS保持在60帧。看起来就像下面这样:

image

当设备需要执行很多混合操作时,问题就出现了:GPU是满载的,但CPU却保持低负载,而显得没有太大用处。

大多数工程师在2010年夏季末时都面临这个问题,当时发布了iPhone 4。Apple发布了革命性的Retina显示屏和…非常普通的GPU。然而,通常情况下它仍然有足够的能力,但上面描述的问题却变得越来越频繁。

你可以在当前运行iOS 7系统的iPhone 4上看到这一现象–所有的应用都变得很慢,即使是最简单的应用。不过,应用这篇文章中的介绍的方法,即使是在这种情况下,你的应用也能达到60 FPS,尽管会有些困难。

所以,需要怎么做呢?事实上,解决方案是:使用CPU来渲染!这将不会加载GPU,这样就无法执行混合操作。例如,在执行动画的CALayer上。

我们可以在UIView的drawRect:方法中使用CoreGraphics操作来执行CPU渲染,如下所示:

image

这段代码nice么?我会告诉你并非如此。甚至通过这种方式,你会撤销在一些UIView上(在任何情况下,它们都是不必要的)的所有缓存优化操作。但是,这种方法禁用了一些混合操作,卸载GPU,从而使UITableView的更顺畅。

但是记住:这提高了渲染性能,不是因为CPU比GPU更快!它可以让我们通过为让CPU来执行某些渲染任务,从而卸载GPU,因为在很多情况下,CPU可能不是100%负载的。

优化混合操作的关键点是在平衡CPU和GPU的负载。

✻ ✻ ✻ ✻ ✻

优化UITableView中绘制数据操作的小结:

  • 减少iOS执行无用混合的区域:不要使用透明背景,使用iOS模拟器或者Instruments来确认这一点;如果可以,尽量使用没有混合的渐变。
  • 优化代码,以平衡CPU和GPU的负载。你需要清楚地知道哪部分渲染需要使用GPU,哪部分可以使用CPU,以此保持平衡。
  • 为特殊的cell类型编写特殊的代码。

像素获取

你知道像素看起来是什么样的么?我的意思是,屏幕上的物理像素是什么样的?我肯定你知道,但我还是想让你看一下:

image

不同的屏幕有不同的制作工艺,但有一件事是一样的。事实上,每个物理像素由三个颜色的子像素组成:红、绿、蓝。

基于这一事实,像素不是原子单位,虽然对于应用来说它是。或者仍然不是?

直到带有Retina屏的iPhone 4发布前,物理像素都可以用整型点坐标来描述。自从有了Retina屏后,在Cocoa Touch环境下,我们就可以用屏幕点来取代像素了,同时屏幕点可以是浮点值。

在完美的世界中(我们尝试构建的),屏幕点总是被处理成物理像素的整型坐标。但在现实生活中它可能是浮点值,例如,线段可能起始于x为0.25的地方。这时候,iOS将执行子像素渲染。

这一技术在应用于特定类型的内容(如文本)时很有意义。但当我们绘制平滑直线时则没有必要。

如果所有的平滑线段都使用子像素渲染技术来渲染,那你会让iOS执行一些不必要的任务,从而降低FPS。

✻ ✻ ✻ ✻ ✻

什么情况下会出现这种不必要的子像素抗锯齿操作呢?最常发生的情况是通过代码计算而变成浮点值的视图坐标,或者是一些不正确的图片资源,这些图片的大小不是对齐到屏幕的物理像素上的(例如,你有一张在Retina显示屏上的大小为60*61的图片,而不是60*60的)。

在前面我们讲到,要解决问题,首先需要找到问题在哪。在iOS模拟器上运行程序,在”Debug“菜单中选中”Color Misaligned Image“。

这一次有两种高亮区域:品红色区域会执行子像素渲染,而黄色区域是图片大小没有对齐的情况。

image

image

那如何在代码中找到对应的位置呢?我总是使用手动布局,并且部分会自定义绘制,所以通常找到这些地方没有任何问题。如果你使用Interface Builder,那我对此深表同情。

通常,为了解决这个问题,你只要简单地使用ceilf, floorf和CGRectIntegral方法来对坐标做四舍五入处理。就是这样!

✻ ✻ ✻ ✻ ✻

通过上面的讨论,我想建议你以下几点:

  • 对所有像素相关的数据做四舍五入处理,包括点坐标,UIView的高度和宽度。
  • 跟踪你的图像资源:图片必须是像素完美的,否则在Retina屏幕上渲染时,它会做不必要的抗锯齿处理。
  • 定期复查你的代码,因为这种情况可以会经常出现。

异步UI

可能这看起来有点奇怪,但这是一种非常有效的方法。如果你知道如何做,那么可以让UITableView滑动得更平滑。

现在我们来讨论一下你应该做什么,然后再讨论下你是否可能这么做。

✻ ✻ ✻ ✻ ✻

每个中等以上规模的应用都可能会使用带有媒体内容的cell:文本、图片、动画,甚至还有视频。

而所有这些都可能带有装饰元素:圆角头像、还’#’号的文本、用户名等。

我们已经多次提及尽可能快地返回cell的需求,而在这里有一些麻烦:clipsToBounds很慢,图片需要从网络加载,需要在字符串中定位#号,和许多其它的问题。

优化的目标是很明确的:如果在主线程中执行这些操作,则会让你不能很快地返回cell。

在后台加载图片,在相同的地方处理圆角,然后将处理后的图片指定给UIImageView。

立刻显示文本,但在后台定位#号,然后使用属性字符串来刷新显示。

在你的cell中,需要具体情况具体分析,但主要的思想是在后台执行大的操作。这可能不止是网络代码,你需要使用Instruments来找到它们。

记住:需要尽快返回cell。

✻ ✻ ✻ ✻ ✻

有时候,上面的所有技术可能都帮不上忙。如GPU仍然不能使用(iPhone4+iOS7)时,cell中有很多内容时,需要CALayer的支持以实现动画时(因为在drawRect:中实现起来真的很麻烦)。

在这种情况下,我们需要在后台渲染所有其它东西。此外它能在用户快速滑动UITableView时有效地提高FPS。

我们来看看Facebook的应用。为了检测这些,你可能需要往下滑足够的高度,然后点击状态栏。列表会往上滑动,因此你可以清楚地看到此时没有渲染cell。如果想要更精确,则不能及时获得。

这很简单,所以你可以自己试试。这时,你需要设置CALayer的drawsAsynchronously属性为YES。

但是我们可以检查这些行为的必要性。在iOS模拟器上运行程序,然后选择“Debug”菜单中的”Color Offscreen-Rendered“。现在所有在后台渲染的区域都被高亮为黄色。

image

image

如果你为某些层开启了这一模式,但是它没有高亮显示,那么它就不够慢。

为了在CALyaer层找到瓶颈并进一步减少它,你可以使用Instruments里面的Time Profiler。

✻ ✻ ✻ ✻ ✻

这里是异步化UI的实现清单:

  • 找到让你的cell无法快速返回的瓶颈。
  • 将操作移到后台线程,并在主线程刷新显示的内容。
  • 最后一招是设置你的CALayer为异步显示模式(即使只是简单的文本或图片)–这将帮你提高FPS。

结论

我尝试解释了iOS绘图系统(没有使用OpenGL,因为它的情况更少)的主要思路。当然有些看起来很模糊,但事实上这只是一些方向,你应该朝着这些方向来检查你的代码以找出影响滚动性能的所有问题。

具体情况具体分析,但原则是不变的。

获取完美平滑滚动的关键是非常特殊的代码,它能让你竭尽iOS的能力来让你的应用更加平滑。

UIKit: UIControl

发表于 2015-12-13   |   分类于 Cocoa

我们在开发应用的时候,经常会用到各种各样的控件,诸如按钮(UIButton)、滑块(UISlider)、分页控件(UIPageControl)等。这些控件用来与用户进行交互,响应用户的操作。我们查看这些类的继承体系,可以看到它们都是继承于UIControl类。UIControl是控件类的基类,它是一个抽象基类,我们不能直接使用UIControl类来实例化控件,它只是为控件子类定义一些通用的接口,并提供一些基础实现,以在事件发生时,预处理这些消息并将它们发送到指定目标对象上。

本文将通过一个自定义的UIControl子类来看看UIControl的基本使用方法。不过在开始之前,让我们先来了解一下Target-Action机制。

Target-Action机制

Target-action是一种设计模式,直译过来就是”目标-行为”。当我们通过代码为一个按钮添加一个点击事件时,通常是如下处理:

1
[button addTarget:self action:@selector(tapButton:) forControlEvents:UIControlEventTouchUpInside];

也就是说,当按钮的点击事件发生时,会将消息发送到target(此处即为self对象),并由target对象的tapButton:方法来处理相应的事件。其基本过程可以用下图来描述:

image

注:图片来源于官方文档Cocoa Application Competencies for iOS - Target Action

即当事件发生时,事件会被发送到控件对象中,然后再由这个控件对象去触发target对象上的action行为,来最终处理事件。因此,Target-Action机制由两部分组成:即目标对象和行为Selector。目标对象指定最终处理事件的对象,而行为Selector则是处理事件的方法。

有关Target-Action机制的具体描述,大家可以参考Cocoa Application Competencies for iOS - Target Action。我们将会在下面讨论一些Target-action更深入的东西。

实例:一个带Label的图片控件

回到我们的正题来,我们将实现一个带Label的图片控件。通常情况下,我们会基于以下两个原因来实现一个自定义的控件:

  • 对于特定的事件,我们需要观察或修改分发到target对象的行为消息。
  • 提供自定义的跟踪行为。

本例将会简单地结合这两者。先来看看效果:

image

这个控件很简单,以图片为背景,然后在下方显示一个Label。

先创建UIControl的一个子类,我们需要传入一个字符串和一个UIImage对象:

1
2
3
4
5
@interface ImageControl : UIControl
- (instancetype)initWithFrame:(CGRect)frame title:(NSString *)title image:(UIImage *)image;
@end

基础的布局我们在此不讨论。我们先来看看UIControl为我们提供了哪些自定义跟踪行为的方法。

跟踪触摸事件

如果是想提供自定义的跟踪行为,则可以重写以下几个方法:

1
2
3
4
- (BOOL)beginTrackingWithTouch:(UITouch *)touch withEvent:(UIEvent *)event
- (BOOL)continueTrackingWithTouch:(UITouch *)touch withEvent:(UIEvent *)event
- (void)endTrackingWithTouch:(UITouch *)touch withEvent:(UIEvent *)event
- (void)cancelTrackingWithEvent:(UIEvent *)event

这四个方法分别对应的时跟踪开始、移动、结束、取消四种状态。看起来是不是很熟悉?这跟UIResponse提供的四个事件跟踪方法是不是挺像的?我们来看看UIResponse的四个方法:

1
2
3
4
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
- (void)touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event

我们可以看到,上面两组方法的参数基本相同,只不过UIControl的是针对单点触摸,而UIResponse可能是多点触摸。另外,返回值也是大同小异。由于UIControl本身是视图,所以它实际上也继承了UIResponse的这四个方法。如果测试一下,我们会发现在针对控件的触摸事件发生时,这两组方法都会被调用,而且互不干涉。

为了判断当前对象是否正在追踪触摸操作,UIControl定义了一个tracking属性。该值如果为YES,则表明正在追踪。这对于我们是更加方便了,不需要自己再去额外定义一个变量来做处理。

在测试中,我们可以发现当我们的触摸点沿着屏幕移出控件区域名,还是会继续追踪触摸操作,cancelTrackingWithEvent:消息并未被发送。为了判断当前触摸点是否在控件区域类,可以使用touchInside属性,这是个只读属性。不过实测的结果是,在控件区域周边一定范围内,该值还是会被标记为YES,即用于判定touchInside为YES的区域会比控件区域要大。

观察或修改分发到target对象的行为消息

对于一个给定的事件,UIControl会调用sendAction:to:forEvent:来将行为消息转发到UIApplication对象,再由UIApplication对象调用其sendAction:to:fromSender:forEvent:方法来将消息分发到指定的target上,而如果我们没有指定target,则会将事件分发到响应链上第一个想处理消息的对象上。而如果子类想监控或修改这种行为的话,则可以重写这个方法。

在我们的实例中,做了个小小的处理,将外部添加的Target-Action放在控件内部来处理事件,因此,我们的代码实现如下:

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
// ImageControl.m
- (void)sendAction:(SEL)action to:(id)target forEvent:(UIEvent *)event {
// 将事件传递到对象本身来处理
[super sendAction:@selector(handleAction:) to:self forEvent:event];
}
- (void)handleAction:(id)sender {
NSLog(@"handle Action");
}
// ViewController.m
- (void)viewDidLoad {
[super viewDidLoad];
self.view.backgroundColor = [UIColor whiteColor];
ImageControl *control = [[ImageControl alloc] initWithFrame:(CGRect){50.0f, 100.0f, 200.0f, 300.0f} title:@"This is a demo" image:[UIImage imageNamed:@"demo"]];
// ...
[control addTarget:self action:@selector(tapImageControl:) forControlEvents:UIControlEventTouchUpInside];
}
- (void)tapImageControl:(id)sender {
NSLog(@"sender = %@", sender);
}

由于我们重写了sendAction:to:forEvent:方法,所以最后处理事件的Selector是ImageControl的handleAction:方法,而不是ViewController的tapImageControl:方法。

另外,sendAction:to:forEvent:实际上也被UIControl的另一个方法所调用,即sendActionsForControlEvents:。这个方法的作用是发送与指定类型相关的所有行为消息。我们可以在任意位置(包括控件内部和外部)调用控件的这个方法来发送参数controlEvents指定的消息。在我们的示例中,在ViewController.m中作了如下测试:

1
2
3
4
5
6
- (void)viewDidLoad {
// ...
[control addTarget:self action:@selector(tapImageControl:) forControlEvents:UIControlEventTouchUpInside];
[control sendActionsForControlEvents:UIControlEventTouchUpInside];
}

可以看到在未点击控件的情况下,触发了UIControlEventTouchUpInside事件,并打印了handle Action日志。

Target-Action的管理

为一个控件对象添加、删除Target-Action的操作我们都已经很熟悉了,主要使用的是以下两个方法:

1
2
3
4
// 添加
- (void)addTarget:(id)target action:(SEL)action forControlEvents:(UIControlEvents)controlEvents
- (void)removeTarget:(id)target action:(SEL)action forControlEvents:(UIControlEvents)controlEvents

如果想获取控件对象所有相关的target对象,则可以调用allTargets方法,该方法返回一个集合。集合中可能包含NSNull对象,表示至少有一个nil目标对象。

而如果想获取某个target对象及事件相关的所有action,则可以调用actionsForTarget:forControlEvent:方法。

不过,这些都是UIControl开放出来的接口。我们还是想要探究一下,UIControl是如何去管理Target-Action的呢?

实际上,我们在程序某个合适的位置打个断点来观察UIControl的内部结构,可以看到这样的结果:

image

因此,UIControl内部实际上是有一个可变数组(_targetActions)来保存Target-Action,数组中的每个元素是一个UIControlTargetAction对象。UIControlTargetAction类是一个私有类,我们可以在iOS-Runtime-Header中找到它的头文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@interface UIControlTargetAction : NSObject {
SEL _action;
BOOL _cancelled;
unsigned int _eventMask;
id _target;
}
@property (nonatomic) BOOL cancelled;
- (void).cxx_destruct;
- (BOOL)cancelled;
- (void)setCancelled:(BOOL)arg1;
@end

可以看到UIControlTargetAction对象维护了一个Target-Action所必须的三要素,即target,action及对应的事件eventMask。

如果仔细想想,会发现一个有意思的问题。我们来看看实例中ViewController(target)与ImageControl实例(control)的引用关系,如下图所示:

image

嗯,循环引用。

既然这样,就必须想办法打破这种循环引用。那么在这5个环节中,哪个地方最适合做这件事呢?仔细思考一样,1、2、4肯定是不行的,3也不太合适,那就只有5了。在上面的UIControlTargetAction头文件中,并没有办法看出_target是以weak方式声明的,那有证据么?

我们在工程中打个Symbolic断点,如下所示:

image

运行程序,程序会进入[UIControl addTarget:action:forControlEvents:]方法的汇编代码页,在这里,我们可以找到一些蛛丝马迹。如下图所示:

image

可以看到,对于_target成员变量,在UIControlTargetAction的初始化方法中调用了objc_storeWeak,即这个成员变量对外部传进来的target对象是以weak的方式引用的。

其实在UIControl的文档中,addTarget:action:forControlEvents:方法的说明还有这么一句:

When you call this method, target is not retained.

另外,如果我们以同一组target-action和event多次调用addTarget:action:forControlEvents:方法,在_targetActions中并不会重复添加UIControlTargetAction对象。

小结

控件是我们在开发中常用的视图工具,能很好的表达用户的意图。我们可以使用UIKit提供的控件,也可以自定义控件。当然,UIControl除了上述的一些方法,还有一些属性和方法,以及一些常量,大家可以参考文档。

示例工程的代码已上传到github,可以在这里下载。另外,推荐一下SVSegmentedControl这个控件,大家可以研究下它的实现。

参考

  1. UIControl Class Reference
  2. UIKit User Interface Catalog - About Controls
  3. Cocoa Application Competencies for iOS - Target Action
  4. iOS-Runtime-Header: UIControlTargetAction
  5. SVSegmentedControl

UIKit: UIImage

发表于 2015-11-22   |   分类于 Cocoa

UIImage对象是iOS中用来显示图像数据的高级接口。我们可以从文件,NSData,Quartz图片对象中创建UIImage对象。可以说这个类是我们接触频率非常高的一个类。

UIImage的不可变性

UIImage对象是不可变的,所以一旦创建后,我们就不能再改变它的属性。这也就意味着,我们只能在初始化方法中提供属性值或依赖于图片自身的属性值。同样,由于其不可变,所以在任何线程中都可以安全地使用它。

如果我们想修改UIImage对象的一些属性,则可以使用便捷方法和自定义的参数值来创建图像的一份拷贝。

另外,由于UIImage对象是不可变的,所以它没有提供访问底层图片数据的方法。不过我们可以使用UIImagePNGRepresentation或UIImageJPEGRepresentation方法来获取包含PNG或JPG格式的数据的NSData对象。如下代码所示:

1
2
3
let image = UIImage(named: "swift");
let imageData:NSData? = UIImageJPEGRepresentation(image!, 1.0)

创建UIImage对象

对于一个UIImage对象来说,它的数据源主要有以下几种:

  1. 文件:我们可以使用init(contentsOfFile:)方法来从指定文件中创建对象。
  2. 纯图片数据(NSData):如果在内存中有图片的原始数据(表示为NSData对象),则可以使用init(data:)来创建。需要注意的是这个方法会对象图片数据做缓存。
  3. CGImage对象:如果我们有一个CGImage对象,则可以使用init(CGImage:)或init(CGImage:scale:orientation:)创建UIImage对象。
  4. CIImage对象:如果我们有一个CIImage对象,则可以使用init(CIImage:)或init(CIImage:scale:orientation:)创建UIImage对象。

需要注意的是,如果是从文件或者纯图片数据中创建UIImage对象,则要求对应的图片格式是系统支持的图片类型。

对于Objective-C来说,UIImage对象也提供了这些初始化方法对应的便捷类方法来创建对象。

内存管理

在实际的应用中,特别是图片类应用中,我们可能需要使用大量的图片。我们都知道,图片通常都是非常占内存的。如果同一时间加载大量的图片,就可能占用大量的系统内存。

为此,Apple采用了一种比较巧妙的策略。在低内存的情况下,系统会强制清除UIImage对象所指向的图片数据,以释放部分内存。注意,这种清除行为影响到的只是图片数据,而不会影响到UIImage对象本身。当我们需要绘制那些图片数据已经被清除的UIImage对象时,对象会自动从源文件中重新加载数据。当然,这是以时间换空间的一种策略,会导致一定的性能损耗。

说到这里,我们不得不提一下init(named:)方法了。可以说我们平时创建UIImage对象用得最多的应该就是这个方法。这个方法主要是使用bundle中的文件创建图片的快捷方式。关于这个方法,有几点需要注意:

  1. 缓存:这个方法会首先去系统缓存中查找是否有图片名对应的图片。如果有就返回缓存中的图片;如果没有,则该方法从磁盘或者asset catalog中加载图片并返回,同时将图片缓存到系统中。缓存的图片只有在收到内存警告时才会释放。因此,如果图片的使用频率比较低,则可以考虑使用imageWithContentsOfFile:方法来加载图片,这样可以减少内存资源的消耗。当然,这需要权衡考虑,毕竟读写磁盘也是有性能消耗的,而且现在的高端机内存已经不小了。
  2. 多分辨率图片处理:在iOS 4.0后,该方法会根据屏幕的分辨率来查找对应尺寸的图片。即我们使用时,只需要写图片名,而不需要指定是1x, 2x还是3x图,该方法会自己判断。
  3. png图片后缀:在iOS 4.0以后,如果图片是png格式的,则图片文件名不需要附带扩展名。
  4. 线程安全性:该方法在iOS 9.0之前并不是线程安全的,在二级线程中调用可能会导致崩溃。在iOS 9.0之后,Apple作了优化处理,将其改为线程安全的方法。为了避免不必要的麻烦,尽量在主线程中调用这个方法。

图片拉伸

当我们的图片比所要填充的区域小时,会导致图片变形。如以下图片,原始大小为100*30,将其放到一个300*50的UIImageView中时,整个图片被拉伸。

原始图片

image

拉伸后的图片

image

这时我们就需要做特殊的处理。

Android的同学应该都知道.9图,这种图片可以只拉伸中间的部分,而保持四个角不变形。在iOS中也支持这种操作。在早期的iOS版本中,UIImage提供了如下方法来执行此操作:

1
func stretchableImageWithLeftCapWidth(_ leftCapWidth: Int, topCapHeight topCapHeight: Int) -> UIImage

这个方法通过leftCapWidth和topCapHeight两个参数来定义四个角的大小。不过这个方法在iOS 5中就被Deprecated了,对应的两个属性leftCapWidth和topCapHeight也是相同的命运。所以现在不建议使用它们。另外,对于如何解释leftCapWidth和topCapHeight,大家可以参考一下@M了个J的iOS图片拉伸技巧。

在iOS 5中,我们可以使用以下方法来执行相同的操作:

1
func resizableImageWithCapInsets(_ capInsets: UIEdgeInsets) -> UIImage

这个方法通过一个UIEdgeInsets来指定上下左右不变形的宽度或高度。它会返回一个新的图像。而如果图像被拉伸,则会以平铺的方式来处理中间的拉伸区域。

我们对上面的图片做如下处理:

1
2
let resizedButtonImageView = UIImageView(image: normalButtonImage?.resizableImageWithCapInsets(UIEdgeInsets(top: 15, left: 15, bottom: 15, right: 15)))
resizedButtonImageView.frame = CGRectMake(0, 60, 300, 50)

其得到的结果如下所示:

image

在iOS 6,Apple又为我们提供了一个新的方法,相较于上面这个方法,只是多一个resizingMode参数,允许我们指定拉伸模式。

1
func resizableImageWithCapInsets(_ capInsets: UIEdgeInsets, resizingMode resizingMode: UIImageResizingMode) -> UIImage

这个方法的拉伸模式分两种:平铺(Tile)和拉伸(Stretch)。如果是平铺模式,则跟前一个方法是一样的效果。

动效图片对象

如果我们有一组大小和缩放因子相同的图片,就可以将这些图片加载到同一个UIImage对象中,形成一个动态的UIImage对象。为此,UIImage提供了以下方法:

1
class func animatedImageNamed(_ name: String, duration duration: NSTimeInterval) -> UIImage?

这个方法会加载以name为基准文件名的一系列文件。如,假设我们的name参数值为”swift”,则这个方法会加载诸如”swift0”, “swift1”,…, “swift1024”这样的一系列的文件。

这里有两个问题需要注意:

  1. 文件的序号必须是从0开始的连续数字,如果不从0开始,则在Playground中是会报错的。而如果中间序号有断,而中断后的图片是不会被加载的。
  2. 所有文件的大小和缩放因子应该是相同的,否则显示时会有不可预期的结果,这种结果主要表现为播放的顺序可能是杂乱的。

如果我们有一组基准文件名不同的文件,但其大小和缩放因子相同,则可能使用以下方法:

1
class func animatedImageWithImages(_ images: [UIImage], duration duration: NSTimeInterval) -> UIImage?

传入一个UIImage数组来拼装一个动效UIImage对象。

另外,UIImage也提供了resizable版本的动效方法,如下所示:

1
2
3
class func animatedResizableImageNamed(_ name: String, capInsets capInsets: UIEdgeInsets, duration duration: NSTimeInterval) -> UIImage?
class func animatedResizableImageNamed(_ name: String, capInsets capInsets: UIEdgeInsets, resizingMode resizingMode: UIImageResizingMode, duration duration: NSTimeInterval) -> UIImage?

第一个方法的UIImageResizingMode默认是UIImageResizingModeTile,所以如果想对图片做拉伸处理,可以使用第二个的方法,并传入UIImageResizingModeStretch。

图片大小的限制

UIImage对象使用的图片大小尽量小于1024*1024。因为这么大的图片消耗的内存过大,在将其作为OpenGL中的贴图或者是绘制到view/layer中时,可以会出现问题。如果仅仅是代码层面的操作的话,则没有这个限制。比如,将一个大于1024*1024的图片绘制到位图图形上下文中以重新设定其大小。事实上,我们需要通过这种操作来改变图片大小,以将其绘制到视图中。

支持的图片格式

UIImage支持的图片格式在UIImage Class Reference中列出来了,大家可以直接参考。

需要注意的一点是RGB-565格式的BMP文件在加载时会被转换成ARGB-1555格式。

示例代码

本文的示例代码已上传到github,可点击这里查看。

参考

  1. UIImage Class Reference
  2. iOS图片拉伸技巧
  3. iOS 处理图片的一些小 Tip

Secret of Swift Performance Part 2 - Look under the hood

发表于 2015-11-05   |   分类于 翻译

原文由Kostiantyn Koval发表于Medium,地址为Secret of Swift Performance :Part 2 - Look under the hood。

当想要分析一个App的性能时,Instruments和Measure绝对是我们最好的朋友。我希望每个人都了解Instruments并至少使用过一次。Instruments提供了许多非常有用的工具,来告诉我们:“我们的App使用了多少内存”,“App有多快”,“有没有内存泄漏”等等。

但作为一个软件攻城狮,我们同样需要知道“为什么…?”,“为什么它发生了?”

在我使用Swift时,我曾经看到一些我当时无法理解但很有意思的东西。“为什么这段代码运行这么快?”为了回答这个问题,我必须查看编译出来的汇编代码。这其实并不难,而且非常有用。接下来就来看看是如何做的。

编译Swift代码

我们只是想编译并分析部分代码,而不是整个工程。为此我们需要做几件事:

  • 创建一个新的Swift文件。
  • 创建一个简单的测试函数func test() { ... // 具体代码 }。
  • 将需要编译和分析的代码拷贝到函数体中。
  • 调用函数。

这里我们做的是定义一个测试函数,函数体是我们想要检查和分析的代码。当然,我们需要在顶层调用这个函数。

现在我们需要编译这个文件。可以借助于xcrun(Xcode工具)和swiftc(Swift编译器命令行工具)。

  • 打开终端并通过cd命令切换到Swift文件所在的目录。
  • 运行命令”xcrun swiftc -Onone inputFile.swift -o resultFileName“。如xcrun swiftc -Onone Assembly.swift -o result。

这个命令将会编译我们的Swift文件。

我们可以使用xcrun swiftc -help命令来查看xcrun swiftc命令的帮助文档。当前我们最感兴趣的是一些优化选项:

  • -O:编译时优化
  • -Onone:编译时不做任何优化
  • -Ounchecked:编译时优化并移除运行时安全检查

使用-O选项很重要。它就跟编译App时使用Release模式一样。通常用来分析要上传到AppStore上的代码。

作为测试,我们使用-Onone模式,因为它生成的汇编代码非常类似于我们的源代码。也可以分别生成两种模式下的代码来做比较。这样我们可以学习下Swift编译器是如何做优化的。

运行:xcrun swiftc -Onone Assembly.swift -o none

会生成一个可执行文件,可以双击运行它。

image

当编译一个Swift文件时,Swift编译器做了以下几件事:

  • 创建一个带有int main(int arg0, int arg1)函数的控制台应用。这是应用的起点。
  • 创建_top_level_code函数。该函数的函数体是Swift文件的顶层可执行代码。在我们的示例中就是调用了test()函数。

获取汇编代码

有许多办法来获取汇编代码。我建议使用Hopper。可以在这里下载并使用Demo模式。使用Hopper最棒的是它可以显示汇编的伪代码,使用起来比较方便。

让我们来获取汇编代码:

  • 打开Hopper > File > Read Executable to Disassemble,选择可执行文件,点击OK

image

image

image

Hopper概述

image

Hopper的界面类似于Xcode,左侧是导航面板,中间是编辑面板,右侧是帮助和Inspector面板。

左侧面板—在这里可以找到所有函数,串标记和字符串,可以点击它们导航到对应的汇编代码。

编辑区— 显示汇编代码,它类似于Xcode的Swift或其它。我们可以使用箭头来导航。

分析代码

首先我们需要找到应用入口,在我们的示例中是_main函数。在左侧导航面板中选择它。下面是_main函数的汇编代码。

image

汇编代码很难分析,不过Hopper可以生成伪代码。使用快捷键”Alt+Enter“或者”Window > Show Pseudo Code of Procedure“。现在可以看到_main函数的伪代码了。

image

这样好多了!!

前4行是提取_main函数的参数,我们对此不感兴趣。然后调用了_top_level_code(),正如前面提到的,这应该就是我们的代码。让我们来看看。关闭伪代码视图,选择_top_level_code函数并显示其伪代码。

image

它只调用了__TF4none4testFT_T_()_函数。

Swift生成的函数有特定的命名规范。即模块名+函数名+字符数+参数类型+其它东西。Mike Ash详细介绍了这一规范。

这里可以看到的是none(文件名), test(函数名)。基于这一点,我们可以说它就是test()函数。让我们来检查一下。查找__TF4none4testFT_T_并显示其伪代码。

image

它有3个变量,是16进制格式的,转换一下:

1
2
3
var_8 = 10,
var_10 = 10,
var_18 = 20

这和我们的源代码非常相似,但源代码有一个相加操作,Swift在编译期直接计算出结果了。

1
2
3
var x = 10
var y = 10
var c = x + y

现在你已经知道了:编译Swift代码,反汇编及分析汇编代码。深入后,你可以学习并发现许多有趣的东西。作为比较,我们现在使用-O模式来编译下代码,以看看Swift编译器是如何优化代码的。

运行xcrun swiftc -O Assembly.swift -o optimized命令。

image

正如你所见的,在主函数中没有调用任何函数。没有_top_level_code。没有调用test()函数。

Swift编译器检测到test函数的结果没有被使用,所以将其忽略。而_top_level_code也只调用了一个test()函数,所以也被忽略了。结果是我们获得了一个空的主函数。

这篇文章描述了如何使用工具来分析代码。我发现了许多用这些工具优化Swift的方法,这些方法非常有意思。我将在第三部分中与你们分享,敬请期待……

注:强烈建议手动操作一下,看看自己得到的结果是什么。

Secret of Swift Performance Part 1 - Measure

发表于 2015-11-05   |   分类于 翻译

原文由Kostiantyn Koval发表于Medium,地址为Secret of Swift Performance :Part 1 — Measure

Swift性能方面的讨论已经很多了。如:它真的比C快么?它怎样才能更快? 去Google一下吧。

但是作为一个App开发者,我们需要知道如何以更简单的方式让我们的App更快。那加速App的银弹又是什么呢?

找出性能瓶颈

找出App的性能瓶颈是很重要的。按照80/20的原则来说,“大约20%的代码占用了80%的运行速度”,这意味着我们需要找出这20%的代码并优化它,而不用关心剩余的80%。

我写了个简单的带有一个闭包参数的测试函数,它的主要功能是测试闭包代码的运行速度。让我们来分析一下这段代码。

1
2
3
4
5
6
7
8
9
func measure(title: String!, call: () -> Void) {
let startTime = CACurrentMediaTime()
call()
let endTime = CACurrentMediaTime()
if let title = title {
print("\(title): ")
}
println("Time - \(endTime - startTime)")
}

这个测试函数的参数有两个:辅助分析的可选名称(title)和类型为()->()的闭包函数。相当简单吧。它在调用call()的前后分别获取了当前时间,并打印出call()执行所花费的时间。

让我们来试一下吧。我有一个函数,它的职责是迭代一个数组,并加载图片。我想看看它需要花费多少时间。我们简单地包装一下这个代码块以方便测试函数调用。在这里我们使用了尾随闭包语义,看上去非常棒。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func doSomeWork() {
measure("Array") {
var ar = [String]()
for i in 0...10000 {
ar.append("New elem \(i)")
}
}
measure("Image") {
let url = NSURL(string: "http://lorempixel.com/1920/1920/")
let image = UIImage(data:NSData(contentsOfURL:url!)!)
}
}

测试结果是

1
2
Array: Time — 0.0845723639995413
Image: Time — 1.77442857499955

image

现在你知道了哪块代码占用了更多时间,然后就需要去优化它或者将其移到二级线程中处理。

提醒

应该总是在Release模式且Optimization Level设置为[-Os]或[-Ofast]的情况下去测试运行速度。

image

12…9
南峰子

南峰子

86 日志
7 分类
© 2017 南峰子
由 Hexo 强力驱动
主题 - NexT.Pisces