0%

SwiftUI 可以说是 WWDC 2019 中最让人激动的技术了,什么是 SwiftUI 呢?官方说法为:SwiftUI is a modern way to declare user interfaces for any Apple platform. Create beautiful, dynamic apps faster than ever before。

总之,这套新的 UI 框架用 WWDC Session 中的话描述就是:

The Shortest Path to a Great App

那下面我们就用 SwiftUI 实现一个 iOS 中最常见的列表页,看看到底 Modern、Faster 在哪里?

First Glance

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct LandMarkView : View {
let landmarks: [LandMark]
var body: some View {
List(landmarks) { landmark in
HStack {
Image(landmark.thumbnail)
Text(landmark.name)
Spacer()

if landmark.isFavorite {
Image(systemName: "star.fill")
.foregroundColor(.yellow)
}
}
}
}
}

同使用 UIKit 写一个列表页进行对比:

  • 不需要实现 UITableViewDelegate 和 UITableViewDataSource,写一堆冗长的代码,在 List(items) 中描述列表的数据,在 List 的 Closure 中描述每个 Cell
  • 不需要使用 AutoLayout 或 Frame 对元素进行排版,HStack(View Container)将元素包起来,简单清晰
  • 当数据 landmarks 有变化时,不需要再调用 reloadData,包括 landmarks 个数有变化或 landmark.isFavorite 值变化,SwiftUI 都会自动更新界面

可以看到,SwiftUI 极大地简化了构建 UI 的过程(Faster),这种耳目一新的构建方式是 Declarative 声明式编程(Modern),而之前 UIKit 的方式是 Imperative 命令式编程,两者有什么区别呢?

Imperative vs Declarative

  • Imperative:命令式,明确而详细的告诉机器做一些事情,从而达到你想要的结果,专注于 How。这种方式更贴近机器思维,CPU 就是一条条执行 PC 指向的机器码。
  • Declarative:声明式,描述你想要什么,交由机器来来完成你想要的,专注于 What。这种方式更贴近人类思维,最开始都是先确定自己想要什么,才会一步步实现。

举个例子,如果我们要去旅游:

  • 对于 Imperative,就是自由行,自己要安排详细的行程,包括购买机票,查询各种交通,预定酒店,预定游玩场所的门票,确定吃饭的餐厅等等。
  • 对于 Declarative,就是跟团游,自己只需要表达想去哪里玩,旅行社或者代理商会帮你安排整个行程。

Declarative 的核心在于描述 What,将 How 委托给一个 Expert 来完成。如何描述 What,这里就涉及到了 DSL 领域描述语言。

在 SwiftUI 之前,我们其实或多或少接触过 Declarative,最典型的就是 SQL,SQL 语句就是一种 DSL,例如对于 SELECT * from product WHERE id = 996 这条语句,只是描述了我们想从 product 表中找到 id 为 996 的商品(What),至于怎么找(How),交给数据库来处理,数据库会高效、健壮的取到数据并返回给我们。

另外,AutoLayout 也可以看成一种简单的 Declarative。

Imperative 和 Declarative 两者各有优缺点,从目前的趋势来看,React/Flutter/SwiftUI 通过 Declarative 来构建 UI,看起来 Declarative 是未来 UI 编程的趋势。为什么大家都不约而同的选择 Declarative 呢?今年 WWDC 中 Apple 工程师给出了答案:


对于一个 App 而言,其代码分为两部分 Basic Features 和 Exciting/Custom Features,让 App 出彩、给用户带来很棒体验的是 Exciting/Custom Features,SwiftUI 的目的就是为了减少开发者在 Basic Features 部分的负担,让开发者更专注于 Exciting/Custom Features。

View

A view defines a piece of UI

上面也提到了,声明式相当于将具体的操作委托给一个 Engine,由 Engine 来做具体的脏活累活,向上提供一个抽象层。在 SwiftUI 中这个抽象层就是 View,SwiftUI 中的 View 不再是 UIKit 中的 UIView,没有 Backing Store,不涉及到真正的渲染,View 只是一个抽象概念,描述 UI 应该如何展示。我们看下 View 的定义:

1
2
3
4
public protocol View : _View {
associatedtype Body : View
var body: Self.Body { get }
}

可以看出,在 SwiftUI 中 View 只是一个 protocol,里面有一个 body 的属性,body 又是 View。这样就可以通过 body 将 View 串起来,形成 View Hierarchy。

Swift 5.1 Magic for SwiftUI DSL

为了实现 SwiftUI 的声明式编程,提供 DSL,Swift 语言在 5.1 中引入了一些新特性:(注:这一节的内容参考 SwiftUI 的一些初步探索 (一) - 小专栏SwiftUI 的 DSL 语法分析 - 知乎 较多)

Opaque Return Types

1
2
3
4
5
struct ContentView: View {
var body: some View {
Text("Hello World")
}
}

上面一段自定义 View 的代码中 var body: some View 这行中多了一个 some,这个 some 是干吗用的?由于 View 只是 protocol,在 Swift 5.1 之前,带有 associatedtype 的协议是不能做为类型来用,只能作为类型约束:

1
2
3
4
5
6
7
8
9
// Error
// Protocol 'View' can only be used as a generic constraint
// because it has Self or associated type requirements
func createView() -> View {
}

// OK
func createView<T: View>() -> T {
}

相当于在声明 body 时,不能用 View,需要指定具体的类型,例如 VStack、Text 等,但如果 body 的类型变化,每次都需要修改,比较麻烦。因此 Swift 5.1 引入了 Opaque Return Types,使用方式是 some protocol,当 body 的类型变成 some View 后,相当于它向编译器作出保证,每次 body 得到的一定是某一个确定的、遵守View协议的类型,但是请编译器“网开一面”,不要再细究具体的类型。返回类型确定单一这个条件十分重要,写成下面的样子编译器会报错:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Error
// Function declares an opaque return type, but the return
// statements in its body do not have matching underlying types
let someCondition: Bool = false

var body: some View {
if someCondition {
return Text("Hello World")
} else {
return Button(action: {}) {
Text("Tap me")
}
}
}

Property Delegates

1
2
3
4
5
6
7
8
9
10
11
struct RoomDetail : View {
let room: Room
@State private var zoomed = false

var body: some View {
Image(room.imageName)
.resizable()
.aspectRatio(contentMode: zoomed ? .fill : .fit)
.tapAction { self.zoomed.toggle() }
}
}

在上面的代码中,一旦 zoomed 的值发生变化,SwiftUI 会自动更新 UI,这一切都源于 @State。State 本质上只是一个自定义类,用 @propertyDelegate 修饰,@State var zoomed 会将 zoomed 的读写转到 State 类中实现了。

1
2
3
@propertyDelegate public struct State<Value>
@propertyDelegate public struct Binding<Value>
@propertyDelegate public struct Environment<Value>

里面 @propertyDelegate 是 Swift 5.1 引入的新特性 Property Delegate,这个特性有什么用呢?假设我们有一个设置页面,需要在 UserDefault 中存储一些属性,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct Preferences {
static var shouldAlert: Bool {
get {
return UserDefaults.standard.object(forKey: "shouldAlert") as? Bool ?? false
} set {
UserDefaults.standard.set(newValue, forKey: "shouldAlert")
}
}
static var refreshRequency: Bool {
get {
return UserDefaults.standard.object(forKey: "refreshRequency") as? TimeInterval ?? 6000
} set {
UserDefaults.standard.set(newValue, forKey: "refreshRequency")
}
}

可以发现 shouldAlert 和 refreshRequency 代码重复较多,如果再多一些设置值,Preferences 这个类会写的烦死。针对这种情况,Swift 5.1 引入 Property Delegate,可以将 Property 的相同行为 Delegate 给一个代理对象去做:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@propertyDelegate
struct UserDefault<T> {
let key: String
let defaultValue: T

var value: T {
get {
return UserDefaults.standard.object(forKey: key) as? T ?? defaultValue
}
set {
UserDefaults.standard.set(newValue, forKey: key)
}
}
}

struct Preferences {
@UserDefault(key: "shouldAlert", defaultValue: false)
static var shouldAlert: Bool

@UserDefault(key: "refreshRequency", defaultValue: 6000)
static var refreshRequency: TimeInterval
}

当使用 @UserDefault(key: "shouldAlert", defaultValue: false) 修饰过 shouldAlert 之后,shouldAlert 会被编译器处理成下面的样子:

1
2
3
4
5
6
7
8
9
10
11
struct Preferences {
static var $shouldAlert = UserDefault<Bool>(key: "shouldAlert", defaultValue: false)
static var shouldAlert: Bool {
get {
return $shouldAlert.value
}
set {
$shouldAlert.value = newValue
}
}
}

回到 @State,当 zoomed 被 @State 修饰后,zoomed 的读写被 Delegate 到 State 类中,SwiftUI 框架在 State 类中根据 zoomed 值的变化去触发界面的更新,达到 Value 变化 UI 自动更新的效果。

Trailing Closure & Function Builder

1
2
3
4
5
6
7
8
9
10
HStack(alignment: .center) {
Image(landmark.thumbnail)
Text(landmark.name)
Spacer()

if landmark.isFavorite {
Image(systemName: "star.fill")
.foregroundColor(.yellow)
}
}

HStack 中 View 与 View 之间没有 , 区分,也没有 return,这种 DSL 的写法主要基于 Swift 的 Trailing Closure 和 Function Builder。下面是 HStack 的定义:

1
2
3
4
public struct HStack<Content> where Content : View {
@inlinable public init(alignment: VerticalAlignment = .center,
spacing: Length? = nil,
@ViewBuilder content: () -> Content)

首先对于 Trailing Closure,如果一个 Swift 方法中最后一个参数是 Closure,则可以将 Closure 提到括号外面。

1
2
3
4
@_functionBuilder public struct ViewBuilder {
public static func buildBlock() -> EmptyView
public static func buildBlock(_ content: Content) -> Content where Content : View
}

其次对于 Function Builder,可以看到 content 前面有一个 @ViewBuilder ,而 ViewBuilder 使用了 @_functionBuilder 修饰,被 @ViewBuilder 修饰过的 Closure 就会被修改语法树,转调 ViewBuilder 的 buildBlock 函数。最终

1
2
3
4
5
6
7
8
9
10
HStack(alignment: .center) {
Image(landmark.thumbnail)
Text(landmark.name)
Spacer()

if landmark.isFavorite {
Image(systemName: "star.fill")
.foregroundColor(.yellow)
}
}

被转换成了

1
2
3
4
5
6
7
HStack(alignment: .center) {
return ViewBuilder.buildBlock(
Image(landmark.thumbnail),
Text(landmark.name),
Spacer()
)
}

最后,Apple 为 SwiftUI 提供了一个无论是内容还是交互都非常棒的官方教程,值得学习 SwiftUI 时跟着教程动手练习,正如同今年 WWDC 的主题一样,一起 Write Code,Blow Minds 吧。

参考

Copying 在 iOS 中有很多概念,例如浅拷贝与深拷贝、copy 与 mutableCopy、NSCopying 协议,一直想彻底搞明白这些概念,刨根问底不搞懂不罢休嘛。于是搜 Google 看了一些博客,又去翻了 Apple 相关的文档,发现网上许多博客都理解错了,下面说说自己的理解。

浅拷贝与深拷贝

对于浅拷贝(Swallow Copy)与深拷贝(Deep Copy),经常看到这样的说法:浅复制是指针拷贝,仅仅拷贝指向对象的指针;深复制是内容拷贝,会拷贝对象本身。 这句话并没有说错,但需要注意的是指针/内容拷贝针对的是谁,无论浅拷贝还是深拷贝,被拷贝的对象都会被复制一份,有新的对象产生,而在复制对象的内容时,对于被拷贝对象中的指针类型的成员变量,浅拷贝只是复制指针,而深拷贝除了复制指针外,会复制指针指向的内容。下面我们以 Apple 官方文档中的图片进行说明:

Object Copy

对普通对象 ObjectA 进行 copy,无论浅拷贝还是深拷贝,都会复制出一个新的对象 ObjectB,只是浅拷贝时 ObjectA 与 ObjectB 中的 textColor 指针还指向同一个 NSColor 对象,而深拷贝时 ObjectA 和 ObjectB 中的 textColor 指针分别指向各自的 NSColor 对象(NSColor 对象被复制了一份)。

Collection Copy

对集合对象 Array1 进行 copy,无论浅拷贝还是深拷贝,都会复制出一个新的对象 Array2,只是浅拷贝时 Array1 与 Array2 中各个元素的指针还指向同一个对象,而深拷贝时 Array1 和 Array2 中各个元素的指针分别指向各自的对象(对象被复制了一份)。

Copy 与 MutableCopy

在说明 copy 与 mutableCopy 之前,我们思考一下:拷贝的目的是什么?在动态库加载时,只读的 TEXT 段是被所有使用动态库的程序共享的, 而可写的 DATA 段会使用 COW(Copy On Write)技术,当某个程序需要修改 DATA 段时会拷贝一份,供此程序专用。因此,拷贝的目的主要用于拷贝一份新的数据进行修改,而不会影响到原有的数据。如果不修改,拷贝就没有必要。

在 iOS 中,有一些系统类根据是否可变进行了区分,例如 NSString 与 NSMutableString,NSArray 与 NSMutableArray 等。为了在两者之间进行转换(我理解这是主要目的),NSObject 提供了 copymutableCopy 方法, copy 复制后对象是不可变对象,mutableCopy 复制后对象是可变对象。对象有不可变对象和可变对象,复制方法有 copymutableCopy,因此存在四种情况:

  1. 不可变对象 copy:对象是不可变的,再复制出一份不可变对象没有意义,因此根本没有发生任何拷贝,对象只有一份。
  2. 不可变对象 mutableCopy:可变对象的能够修改,原来的不可变对象不支持,因此需要复制出一个新对象,是浅拷贝
  3. 可变对象 copy:不可变对象不能修改,原来的可变对象不支持,因此需要复制出新对象,是浅拷贝
  4. 可变对象 mutableCopy:可变对象的修改不应该影响到原来的可变对象,因此需要复制出新对象,是浅拷贝

如何进行深拷贝呢?

对于集合类型的对象,将 initWithArray:copyItems: 第二个参数设置成 YES 时,会对集合内每一个元素发送 copyWithZone: 消息,元素进行复制,但是对于元素中指针类型的成员变量,依然是浅拷贝,因此这种拷贝被称为单层深拷贝(one-level-deep copy)。

如果想进行完全的深拷贝,可以先通过 NSKeyedArchiver 将对象归档,再通过 NSKeyedUnarchiver 将对象解归档。由于在归档时,对象中每个成员变量都会收到 encodeWithCoder: 消息,相当于将对象所有的数据均序列化保存到磁盘上(可以看成换了种数据格式的拷贝),再通过 initWithCoder: 解归档时,就将拷贝过的数据经过转换后读取出来,深拷贝。

NSCopying

如果自定义的类也想要支持 copymutableCopy 方法,就需要实现 NSCopyingNSMutableCopying 协议。在实现 copyWithZone: 方法时需要注意:

  • copyWithZone: 相当于新创建一个对象,并将当前对象的值复制到新创建的对象中。设置时应直接访问成员变量而不是通过属性访问。
  • 直接从 NSObject 继承的类,应使用 [[[self class] allocWithZone:zone] init],使得在创建新对象时能够使用正确的类。
  • 父类中已经实现了 copyWithZone: 时,应先调用父类的方法,让父类创建对应的对象(self class 能保证创建对象是正确的),并拷贝父类中定义的成员变量。
1
2
3
4
5
- (id)copyWithZone:(NSZone *)zone {
YourClass *object = [super copyWithZone:zone];
_property = xxx;
return object;
}

参考:

关于 iOS 中多行文字行间距这个问题蛋疼了几年了,回忆一下整个历程:

一开始,UI 同学使用 PhotoShop 实现 UI 稿,PhotoShop 的 Label 在相同字体下的高度与 iOS 比就不准,并且使用标注工具进行文字标注时总是紧贴着字形的上下边进行标注,而字体本身有 LineHeight,字形上下是有间距的。为了达到 UI 稿效果,只能用模拟器对着相同尺寸 UI 稿,用标尺工具一点点比较,试出间距值,标注值仅供参考。

后来 UI 同学换成了用 Sketch 实现 UI 稿,由于 Sketch 使用和 iOS 相同的文本渲染技术,在 Sketch 上新建一个 Label,文本带 LineHeight,有间距,单行文字或文字与其他元素之间的间距终于准确了。

但是 Sketch 中处理多行文本时只有 LineHeight 的概念,没有 UILabel 中 LineSpacing 的概念,LineSpacing 只会在行与行中间添加间距,每一行的 LineHeight 保持不变,导致 UI 稿中多行文字修改 LineHeight 之后,用 LineSpacing 并不能完美匹配 UI 稿效果,而且 LineHeight 的变化也会导致文本在和其他控件对齐时与标注对不上。NSParagraphyStyle 虽然有 maximumLineHeight 和 minimumLineHeight 属性,但设置以后是在文本顶部多出间距,而不是上下均匀间距。为了解决这个问题,参考过 iOS 文本对齐,如何像素般精确还原设计稿,使用 Sketch 插件将 LineHeight 修正成 LineSpacing 的效果,但 UI 同学反馈插件不能用,我也没仔细研究如何定制 Sketch 插件,另外,每次用插件修正也比较麻烦,UI 同学存在遗漏的可能性。

另外,iOS 的 LineSpacing 一直有个 Bug,一旦中文设置了 LineSpacing,在单行情况下底部会多出 LineSpacing 的间距,多行时就没有这个问题,英文单行也没有这个问题。为了解决这个问题,会判断文字是否超过了一行,如果不超过一行就不设置 LineSpacing。后来嫌麻烦,直接用 baseline 对齐而不是 bottom 对齐,offset 需要加上字体 descent 的大小。

今天偶然看到了 在iOS中如何正确的实现行间距与行高 - 掘金 这篇文章,豁然开朗。虽然设置 maximumLineHeight 和 minimumLineHeight 会导致显示有偏移,但整体高度是对的,利用 baselineOffset 将偏移修复即可,修复公式为 (lineHeight - label.font.lineHeight) / 4

经过同 Sketch 对比,与 UI 效果一致。由于设置的是 LineHeight,中文单行文字也没有了底部多出间隔的问题了。最后将相关代码抽成一个 Utils,以后如果 UI 修改了文字的 LineHeight,直接使用这个 Utils 配置 NSAttributedString,就能完美适配 UI 的效果和标注,神清气爽!

1
2
3
4
5
6
7
NSMutableParagraphStyle *paragraphStyle = [NSMutableParagraphStyle new];
paragraphStyle.maximumLineHeight = lineHeight;
paragraphStyle.minimumLineHeight = lineHeight;
NSMutableDictionary *attributes = [NSMutableDictionary dictionary];
attributes[NSParagraphStyleAttributeName] = paragraphStyle;
CGFloat baselineOffset = (lineHeight - font.lineHeight) / 4;
attributes[NSBaselineOffsetAttributeName] = @(baselineOffset);

一些注意事项:

  1. 每种字体的 LineHeight 是不同的,例如 SFUI 的 LineHeight 是字号的 1.2 倍,PingFangSC 的 LineHeight 是字号的 1.4 倍。
  2. SFUI 中没有中文字体,最后系统会 fall back 到 PingFangSC,字形的显示是相同的,但是由于字体不用,导致 LineHeight 不一样。用 systemFontOfSize:sizefontWithName:@"PingFangSC-Regular" size:size 设置 UILabel 的 font,相同中文内容的 UILabel 高度不一样。
  3. baselineOffset 很奇怪,移动的效果是设置值的两倍,例如设置 1 pt,向上移动 2 pt,所以修复公式最后是 / 4 而不是 / 2。

懒惰被视为七宗罪之一,是个贬义词,但在一定场景下,懒惰变成了褒义词:在我看来,懒惰才是人类进步的关键,正是因为懒,才创造出各种各样的工具来提升效率。人们懒得走路,发明了自行车,后来懒得蹬车,就发明了汽车,最近连开车都懒得开了,于是出现了自动驾驶汽车。对于工程师而言,懒惰也分两种,这两种类型的懒惰会使工程师的成长出现截然不同的道路。

有利的懒惰

有利的懒惰是指讨厌重复而低效的任务,自己懒得做,就让工具做,将重复任务自动化。有利的懒惰能够极大地提高效率,节约时间。下面举几个例子:

CocoaPods

CocoaPods 是开发 OS X 和 iOS 应用程序时一个第三方库的依赖管理工具。在 CocoaPods 出现之前,需要添加一个第三方库需要以下操作:

  1. 下载第三方库的代码。
  2. 将第三方库的代码引入工程,并添加第三方库所需的 Framework。
  3. 解决库与库之间、库与工程之间的依赖关系,检测重复添加的 Framework。
  4. 如果第三方库有更新,需要将库从工程中删除,并重复上面的步骤。

哦买噶,这些重复繁琐的工作能把人烦死,有些“懒惰”的工程师无法忍受这种情况,于是 CocoaPods 出现了,它能够自动下载配置文件中指定的第三方库,处理库与库之间的依赖关系,并通过新建一个 xcworkspace 的方式将第三方库同工程连接起来。哈利路亚,感觉整个世界清净了。

ARC 与 Block

ARC 为什么会出现呢?因为在 MRC 下每次都要 retain/release 真是太麻烦了,而且还容易不配对导致内存泄露,估计苹果的工程师都写烦了,既然编译器能够识别出对象的生命周期,那就让编译器去做内存管理吧,简单省事。有人可能不放心把内存管理交给编译器,你放心,在识别对象生命周期这件事上,编译器比你厉害,再厉害的开发者也有可能因为一时疏忽而遗漏,但编译器不会。另外,会有人认为 ARC 会影响性能,这其实是不理解 ARC 的原理:ARC 不是垃圾回收,只是自动帮你写 retain/release,而且写 retain/relese 时不再经过消息传递,是直接调用对应的 C 函数,这会提升性能的。另外,对于工厂方法返回值,ARC 也会做优化,不再将返回的对象放入 AutoReleasePool 了,而是直接返回,相当于调 alloc + init。所以放心的使用 ARC 吧,这种提高效率的东西为什么不用?

Block 为什么会出现呢?在我看来,是因为在使用回调函数时,每次使用变量都要将变量整合到一个结构体中,用 void * 指针的形式传递给回调函数的 context 参数,真是太麻烦了。编译器既然能识别出在回调函数里使用了哪些变量,就自动地跟回调函数整合成为一个对象吧,这样在回调函数中就能直接使用了。

《iOS 开发进阶》中的脚本

唐巧《iOS 开发进阶》中让我印象最深的是实战技巧里的一些脚本,例如删除未使用的图片资源、检查图片长宽是否是偶数等,虽然都是些简单操作,但是能提升效率,感觉很棒。

我写过的一个自动部署工具

在中科院实习的时候,曾经负责开发维护一个嵌入式系统,代码是跑在一块 ARM 开发板上的,因此每次交叉编译过后需要通过 FTP 将包传到开发板上解压,并配置一下 rcS 启动脚本。开发阶段只是一块 ARM 板,手动部署就还好,后来变成了十五块 ARM 板,这下我不干了,手动部署会死人的,而且一旦程序有 Bug,就要重新部署一遍。于是“懒惰”的我写了一个自动部署工具,思路就是轮询目标 ARM 板的 IP 地址,针对每个 IP,先通过 FTP 将程序包上传,再通过 Telnet 输入解压程序包以及覆盖 rcS 启动脚本的指令,将整个过程自动化。由于懒得每次 IP 地址改变就重新编译程序,因此将 IP 地址、FTP 账户密码等信息从程序中抽出来,放到一个配置文件中,每次启动时读取(也算是一种依赖注入了)。同时也懒得每次 Telnet 输入的命令改变就重新编译程序,将 Telnet 要输入的命令也写到一个文本文件中,动态读取。写好了之后,手动部署估计要两个小时的活,一行命令搞定,感觉生活顿时美好了许多。

如果仔细观察,还有非常多的例子,其实要做到这一点与能力无关,与方向无关,与规模无关,只跟态度有关,包括个人与团队的态度。对于个人而言,遇到重复工作,是就这样低效地重复下去,还是思考用自动化提高效率?对于团队而言,是否给成员时间来完成一些能够提高效率的工具?工程师文化越是浓厚的团队,各种工具就越多,效率就越高。总之,人并不擅长做重复枯燥的工作,而这些工作恰恰是机器擅长的,想办法交给机器去做吧,遵循 DIY:

Don’t Repeat Yourself!

不利的懒惰

下面这些不利的懒惰会极大地妨碍我们成为优秀的工程师(在写下面的内容时,我也在不断反思自己,发现其实自己许多地方依然犯了懒惰的错误,边写边出汗,膝盖各种中箭。。。):

懒得搜索

我记得微博上有过一张亚一程 Laruence 一段群对话的截图,里面是这样说的:“不是我说你,这么简单的问题,你不 Google,不百度,来群里问,简直是舍近求远”。其实真正原因就是懒。在现在这个时代,搜索是无比强大的工具,想想看,世界那么大,就去搜一搜,你不会是第一个遇到问题的,也不会是最后一个遇到的,我觉得,Google + StackOverflow + Github + Dash 基本上能解决99%的问题。

我们经常会遇到搜索不到答案的过程,于是很多人就放弃了,回到了到处去问的老路上。其实搜索不到答案的原因有2点:1,我们没有正确描述以及抽象问题,找对关键字。2,我们没有用搜索引擎的思维思考。遇到搜索不到的情况时,不要放弃,努力思考如何修改关键字与描述,多试几次,虽然很痛苦,但痛苦说明我们的大脑在形成新的思维模式,一旦形成,我们的搜索会越来越准确,效率也会越来越高。

哦,对了,最后提醒一下,对于技术问题,还是避免使用百度吧,真的搜不到什么有用的东西。有人会说用 Google 还要科学上网,多麻烦,相比搜索到有效答案带来的收益,翻墙这点工作真不算什么,我们可是工程师啊,反思下是不是因为懒所以不愿意用 Google?

懒得思考

我们在学习一个知识的时候,要积极思考,不能死记硬背。一种框架/特性出现时,一定有它的原因,多想想为什么会出现?解决了什么样的问题?为什么要这样做?这样做的好处是什么?原理是什么?到底是如何实现的?保持强烈的好奇心,这会使我们不断发问,在回答问题时会不断思考,而只有不断的思考才能真正理解一个知识,从而能够更好的使用。

另外,我们在遇到问题后,往往会搜到解决方案只是简单的拷贝,不分析背后的原因,不分析解决方案会造成哪些影响。Bug 是那磨人的小妖精,这次不彻底搞清楚原因,下次它还会来烦我们,我们就会成为传说中的救火队长,哪里着火灭哪里,疲于奔命,但火却越灭越多。

懒得阅读

现在不是知识匮乏,而是知识爆炸,如果想学习,有太多的东西可以学了:

  • 书:iOS 的经典书籍,随便一本都能让人受益匪浅。
  • 博客:有太多优秀的博客了,那都是别人深思熟虑的精华,花了数个小时写出来的。
  • 文档:很多时候,StackOverflow 回答问题的方式就是贴上一段官方开发文档上的文字,或者接口 API 的说明,在看不到源码的情况下,官方文档可以看出源码的接口说明,很值得一读。用 Dash 或 Xcode 自带文档工具,遇到不清楚的点就去看一看。
  • 源码。Reading the Fucking SOURCE CODE 不是一句空话,源码之下无秘密。有些效果不知道怎么做,到 GitHub 上搜一搜,看懂了自己不就会了。

总之,Stay Hungry,Stay Foolish!

懒得动手尝试

看看这篇《Leveling Up》,纸上得来终觉浅,绝知此事要躬行,动手才是学习最有效的方法:

  • 在看别人教程时,把 Demo 下载,自己跑一跑,改改参数,或者自己尝试重新写一遍,效果绝对比只看要好。自己有疑问时或者有想法时,都可以写个 Demo 实验一下。
  • 在看 Objective-C Runtime 原理时,亲自用 clang -rewrite-objc file.m 将 .m 文件转成 .cpp 文件看一看。用 Associated Object 给 Category 加属性时都自己写段代码试一试。
  • 想看系统函数的调用情况,可以用 Method Swizzle 给一些系统方法加一些“装饰”,或者还可以用符号断点。没事干找台越狱手机用 Reveal 看看别人家的 App。

懒得改进优化

唯一不变的就是变化。代码在最初时由于业务简单一般都很不错,但往往在增加新需求/需求变化时开始出现坏味道,因为需求的变化经常导致大环境变化,而不同环境下的实现是不同的,例如网站支持100人访问与支持100000人访问是两种实现方式,控件只支持一行显示与支持多行显示也是两种实现方式。PM 有时候意识不到需求变化背后隐含的环境变化对技术实现的影响,觉得不就是简单的改一下,有什么难的?对啊,把大象装冰箱里也只需要三步,有什么难的?为了应对这些变化,工程师有时需要对结构进行调整,保证结构的灵活,在下次变化来临时更从容,这种调整就是重构。重构不是洪水猛兽,重构可以很大,也可以很小,一切在于时间点,修改的时间点越早,成本就越低,不然就会欠下技术债。在逻辑的世界里,只分对错,欠下的一定会还。不要为了一时便利而忽略了可持续性,切记技术债是高利贷,利滚利,拖得时间越久,成本就越高,到最后一定会连本带利让欠债者赔个精光。

因此工程师在实现需求时一定要留出 Buffer 来处理结构变化引起的重构与遗留代码带来的技术债,不能懒,这样以后的需求才会更好做。而团队在每个迭代中也应考虑将一些技术债与优化作为需求加入到需求池中,不然代码的坏味道就开始在工程中弥漫,需求越做越慢,Bug 越做越多,为了速度,开始拼命加班招人,效率却越来越低,开始进入恶性循环。

懒得总结

在我看来,经验从来不是比拼总时间,而是比拼效果。有些人多年经验却不如有些人一年经验,这是为何?关键在于总结。就拿圣斗士星矢来说,如果单论时间,他能当上青铜圣斗士都很勉强了,为什么他能打败黄金圣斗士,因为他说过:圣斗士不会败给同一招两次!犯错掉进坑里不要紧,谁没有犯过错?但掉进两次掉进同一个坑就不太好了,而有效的经验能让我们以后不再犯相同或类似错误。

如何克服这些问题

我仔细观察过一些优秀的 iOS 工程师,发现:

  • 每年都有 WWDC,大家都能看,但喵神 onevcat 总能写出高质量的笔记与总结
  • 同样是学 Objective-C,阳神孙源能玩出花来,挖掘出各种特性与原理。(我曾经在阳神的博客上问过一个问题,阳神告诉我他是通过反解汇编代码得出的,我就意识到自己犯了懒于尝试的错误了)
  • 动画狂魔叶孤城_与动画小王子 KITTEN-YANG 的动画屌炸天,不用问他们为何如此屌,去他们的 GitHub 上看看他们各种尝试动画的 Demo 就知道了。
  • 唐巧《iOS 开发进阶》让我收获最多的不是里面的知识,而是他学习与总结的方式,我不断的反思自己,我平时学习时,是否能像他一样总结出一本自己的 iOS 学习笔记。

还有许多优秀的 iOS 工程师这里就不一一举例了,我认为,这些优秀的 iOS 工程师并没有比你我聪明,跟我们一样只是普通人,但他们在上面这些事情上不懒惰,积极思考、尝试、总结,在同样的条件下收获多一点点,日积月累,于是他们变得优秀。不要小看这一点点,我们要相信积累的力量,水滴石穿啊,tinyfool 说过,这种积累所达到的层次,很难被人短时间追赶上,需要别人同样去积累,是非常具有竞争力的。

面对这些优秀的 iOS 工程师,我们经常会犯另一种懒惰的错误:我们总想加好友,攀交情,甚至用拉低姿态的方式,总觉得自己抱上大腿就能迅速成长,迅速变牛。这其实是种假象,是自己不自信不独立的表现。即使加了好友,他们能够答疑解惑,甚至手把手教,亲自帮忙解决问题又怎样,那还是别人的东西,自己没有任何成长,自己不就犯了懒惰的错误吗?

学习与成长从来没有捷径,也只能靠自己!

除了欣赏与钦佩这些优秀的人外,我觉得更重要的是默默地观察与努力,观察他们是如何成长的,学习他们的好习惯,努力提升自己,向他们看齐,当有一天达到了他们的水平之后,无需刻意培养,同他们自会相熟,因为优秀的人总是互相吸引互相欣赏,“臭味相投”,不是吗?

总之,借用《学 iOS 开发的一些经验》里的一段话来激励自己,努力成为一名“懒惰”而不懒惰的优秀工程师:

我觉得支撑我们不断探索和前进的动力不是兴趣,而是永不满足的好奇心,和对优雅代码的追求。

与技术无关的懒惰

最后提一下工程师非常常见的懒惰:懒得锻炼,不注意自己身体。在我看来,我们可以热爱编程,热爱自己的工作,热爱自己的事业,热爱自己的公司,但这些不是最重要的,最重要的是我们自己的身体与我们的家庭。为什么呢?因为对于公司而言,我们是可以替代的,无论我们多么牛,多么重要,少了我们,公司依然可以运作。但对于身体与家庭而言,我们是不可替代的!身体不是程序,不能重置,一旦身体坏了,就很难恢复,甚至继续恶化,伴随一生。一旦我们走了,我们的父母、配偶、子女就失去了唯一的我们,这带来的伤害与损失对于家庭而言是无法估量的,甚至为持续一辈子(看看那些失孤的老人,让人心碎啊)。孰重孰轻,我相信理智的工程师会做出自己的决定。

我这里不是说我们不要奋斗不要拼搏,这跟锻炼身体完全是互不影响的,锻炼身体甚至能让我们能够更好的拼搏与奋斗。我们不能学习现在敏捷的一些错误做法,只强调快,却忽略了可持续性(敏捷强调的是可持续性的快速迭代),所以不要拼一天的工作时长,留出点时间锻炼健身,我们都加过班,长时间加班加到后面其实脑子已经不够敏锐了,效率极低,对编程这种强脑力劳动是很不利的,易出问题,还不如回去跑跑步,早点休息,明天更高效完成就好了。有时候公司或团队的氛围就是不把工程师当人看,疯狂加班,幻想着靠十个女人怀孕一个月就能把孩子生出来,我觉得实在受不了就换一家吧,没什么大不了,开心健康地活着其实就是在赚钱。(医院才是真正的销金窟,钱到那个时候就是个数字,医院里充斥着痛苦、无助、绝望、麻木,唯独没有幸福,谁经历过谁知道)

另外,在工程师的眼里,既然一切都是逻辑,为什么不把自己的身体当做程序来调试与优化呢?一样的都有输入输出,对高热量食物防御式编程等,我曾经这样试过,成功减肥30斤,当我能够穿上一条许久都穿不上的裤子时,相信我,那种成就感比写100个牛逼程序或 GitHub 上有一个10000+ Star 的仓库都要强。

版权申明:我已将本文在微信公众平台的发表权版权声明:我已将本文在微信公众平台的发表权「独家代理」给 iOS 开发(iOSDevTips)微信公众号。扫下方二维码即可关注「iOS 开发」:

Xcode 的插件大大丰富了 Xcode 的功能,而且有了 Alcatraz ,插件的管理也非常容易,像我这种 Vim 党完全离不开 XVim。但是有个非常恼人的问题:一旦升级 Xcode ,插件就失效!

之前 Xcode 升级到 6.2 的时候遇到过插件失效的问题,Google 之后把一段很长命令复制到 Terminal 后运行一下即可,当时一看解决了,顿时觉得满足感爆棚,自己可以拯救地球了~就没有再深入,结果升级到 6.3 时又遇到了。“同样的招式对圣斗士是不能使用第二次的!”,同样的坑对有节操的程序员是不能掉进去第二次的!因此这一次一定要搞清楚为什么会这样,以后再次遇到了如何解决。

问题原因

Xcode 的插件放置在 ~/Library/Application\ Support/Developer/Shared/Xcode/Plug-ins 目录下,为 .xcplugin 格式。通过 Show Content 可以看到 xcplugin 中存在一个 Info.plist,其中有一项为 DVTPlugInCompatibilityUUIDs,而这就是插件失效的原因。

由于 Apple 没有公开插件开发的相关资料,这里我只能通过命名跟值猜测 DVTPlugInCompatibilityUUIDs 的作用:插件通过 DVTPlugInCompatibilityUUIDs 来指定能够运行此插件的 Xcode 版本。因此,DVTPlugInCompatibilityUUIDs 中存放的是 Xcode 版本对应的 UUID,Xcode 在启动加载控件时,将当前 UUID 同插件 Info.plist 中 DVTPlugInCompatibilityUUIDs 存放的 UUID 数组进行匹配,如果没有匹配项,说明此插件无法在该版本的 Xcode 运行,插件也就失效了。

解决办法

解决办法非常简单:将当前版本的 UUID 加到 DVTPlugInCompatibilityUUIDs 中即可。但是插件比较多(1个及以上)的情况下,一个个的打开修改非常无聊跟低效,作为“懒惰”的程序员,这时候就要用上命令行,让重复劳动自动化。思路为将命令分为两部分:

  1. 通过 find 命令在插件目录下找到所有插件的 Info.plist 文件。
  2. 通过 xargs 命令对上一步的搜索结果进行“for 循环”(就这样理解吧),针对每一个 Info.plist 文件,利用 defaults write 命令将当前版本的 UUID 加到 DVTPlugInCompatibilityUUIDs 中。

此时问题来了,挖掘机技术。。。不对,是如何获取当前版本 Xcode 的 UUID 呢?首先关掉 Xcode,打开 Terminal,输入 tail -f /var/log/system.log,再次打开 Xcode,就能看到如下 log 信息:

[MT] PluginLoading: Required plug-in compatibility UUID 9F75337B-21B4-4ADC-B558-F9CADF7073A7 for plug-in at path ‘~/Library/Application Support/Developer/Shared/Xcode/Plug-ins/Alcatraz.xcplugin’ not present in DVTPlugInCompatibilityUUIDs

可以看到,log 信息表明 Xcode 加载插件失败的原因,并且能够看到当前版本(6.3)Xcode 的 UUID 为 9F75337B-21B4-4ADC-B558-F9CADF7073A7。经过 @Kyrrr 的提醒,有一种更好的方式来获取当前版本 Xcode 的 UUID:通过 defaults read 命令从 Xcode 的 Info.plist 读取 DVTPlugInCompatibilityUUID。

最终的命令为:

1
find ~/Library/Application\ Support/Developer/Shared/Xcode/Plug-ins -name Info.plist -maxdepth 3 | xargs -I{} defaults write {} DVTPlugInCompatibilityUUIDs -array-add `defaults read /Applications/Xcode.app/Contents/Info.plist DVTPlugInCompatibilityUUID`

在 Terminal 中运行上述命令就解决了插件失效的问题,在插件 Info.plist 的 DVTPlugInCompatibilityUUIDs 中也能看到新增的 UUID 了。

最近在看《iOS CoreAnimation: Advanced Techiques》时,不太理解 CALayer 的 contentsScale 属性,在后面的 CATextLayer 中再次遇到,于是花功夫 Google 了一下各类文档,下面说说自己对 contentsScale 的理解,可能涉及的方面有些多:

Point与Pixel

iOS 中的绘图系统使用的尺寸单位为 Point,而屏幕显示的单位为 Pixel,为什么要这样做呢?其实就是为了隔离变化:对于绘图而言,并不关心如何在屏幕上显示,这些属于硬件细节,也不应该关心,因此框架使用了万金油方法——抽象,绘图使用与硬件无关的 Point,系统根据当前屏幕的情况自动将 Point 转成 Pixel,所以不论以后硬件屏幕如何变化,使用 Point 的绘图系统以不变应万变,这就是抽象的好处!

对于非 Retina 与 Retina 的屏幕,Point 与 Pixel 的转化关系如下:

  • 非 Retina:1 Point = 1 x 1 Pixel
  • Retina:1 Point = 2 x 2 Pixel

系统通过一个变量来表示这种映射关系,这就是 UIScreen 中的 scale 属性的作用。需要注意的是,Retina 屏幕 1 个 Point 对应 4 个 Pixel,但 scale 为 2,为什么不是4呢?因为在屏幕的二维空间中,一切可显示的物体都有 X 轴跟 Y 轴性质,Retina 屏幕的映射是 X 跟 Y 两个方向同时放大两倍实现的,所以 scale 为 2(这也是为什么上面写成 2 x 2 而不是直接为 4 的原因)。

CALayer 与 Render

在 iOS 中,如果想要显示什么,可能第一时间想到的就是 UIView 及其子类,但是 UIView 本身其实并不负责显示,从 UIView 从 UIResponder 继承可以看出,UIView 的主要任务是响应触摸事件(在责任链中),那 UIView 是如何实现显示的呢?通过组合,将责任委托给成员变量:每个 UIView 都有一个 Backing Layer,UIView 将显示的任务就交给 CALayer 这个小弟啦,自己作为一个 Wrapper,将 CALayer 一些比较复杂的操作封装成简单已用的接口,供外部使用。下图就是二者的关系图(直接用笔记中的手绘,见谅):

因此对纹理封装的 CALayer 才是显示的核心,CALayer 的 contents 才指定了真正的要显示的内容,理解了这一点,下面就开始介绍正主contentsScale(现在才开始真是醉了),按照惯例,先看下官方文档,我截取了一段:

This value defines the mapping between the logical coordinate space of the layer (measured in points) and the physical coordinate space (measured in pixels). Higher scale factors indicate that each point in the layer is represented by more than one pixel at render time.
Core Animation uses the value you specify as a cue to determine how to render your content.

官方文档有两个关键点:

  1. contentsScale 决定了 CALayer 的内容如何从 Point 映射到 Pixel。
  2. contentsScale 决定了 CALayer 的内容如何被渲染。

为了理解这两个关键点,就要介绍下 CALayer 内容的来源了。CALayer 内容的来源有两种:

1. 通过 Core Graphics 自定义绘图到 CALayer 的 Backing Store 中

CALayer 会根据 contentsScale 创建 Backing Store,并且根据 contentScale 设置 Context 的 CTM(Concurrent Transform Matrix)。例如,Layer 的尺寸为 10 Point x 10 Point,当contentsScale 为 2.0 时,最后生成的 Backing Store 大小为 20 Pixel x 20 Pixel,并且在将创建的 Graphics Context 传入 drawLayer:InContext: 前,会调用 CGContextScaleCTM(context, 2, 2) 进行放大,这样会使生成的内容自动满足屏幕的要求。

2. 将CGImage类型的图片设置为CALayer的contents

图片的尺寸是像素相关的,iOS 在加载图片时,通过图片的名称进行处理。例如对于 @2x 的图片,在加载为 UIImage 时,会将 scale 设置为 2,size 为像素大小除以 scale,这样就保证了 UIImage 在 Retina 屏幕上能够正常显示。但需要注意的是:将 UIImage 转换成 CGImage 时会丢失 scale 属性,使用 CGImageGetWidth/Height 时得到的是像素尺寸。

与 Core Graphics 生成内容不同的是,图片作为纹理已经被上传至 GPU,CALayer 不需要分配 Backing Store,contentsScale 会在渲染时起作用:对于 Retina 的屏幕,如果 contentsScale 为 2.0,与屏幕的 scale 匹配,则渲染系统不对内容进行处理,如果 contentsScale 为 1.0,说明 Layer 的内容并不匹配屏幕 scale,渲染系统会对 Layer 的内容进行两倍的 Scale 操作。由于图片本身内容没那么多,于是渲染系统会填充像素,导致模糊。

总之,contentsScale 描述了 CALayer 内容的 Scale 特性,CALayer 在生成内容时会根据 contentsScale 做处理,并且渲染系统根据 contentsScale 进行渲染。另,《编写可读代码的艺术》中写到的“名字才是最好的注释”在这里得到很好的诠释,理解了 contentsScale 之后,再回过头看看命名,清晰准确,值得学习。

需要注意 scale 的地方

  1. CALayer 的 contentsScale 默认为 1.0,只有在使用 Core Graphics 在 drawRect: 中自定义绘图时系统才会根据当前屏幕的情况设置,因此以下情况需要设置合适的 contentsScale
  • 直接设置 CALayer 的 contents 时。
  • 创建新的 CALayer 时。例如,CATextLayer 在 Retina 屏幕时如果不设置 contentsScale,所显示的文字就会模糊。
  1. 在使用 Image Context 时需要注意:UIGraphicsBeginImageContext 以 1.0 的比例系数创建 Bitmap,所以当屏幕为 Retina 时,在渲染时可能会显得模糊。要创建比例系数为其它值的图片,需要使用 UIGraphicsBeginImageContextWithOptions

参考

1. 出现原因

自从块(Block)方式的 animateWithDuration:delay:options:animations:completion: 方法出现之后,大家就使用此方法替换原来笨重且不清晰的旧方法来实现动画。但是在实现动画序列的时候,问题来了,看下面的代码:

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
[UIView animateWithDuration:0.25 animations:^{
self.imageView.alpha = 0.0;
} completion:^(BOOL finished) {
if (finished) {
[UIView animateWithDuration:0.25 animations:^{
self.headline.alpha = 0.0;
} completion:^(BOOL finished) {
if (finished) {
[UIView animateWithDuration:0.25 animations:^{
self.content.alpha = 0.0;
} completion:^(BOOL finished) {
if (finished) {
[UIView animateWithDuration:0.25 animations:^{
self.headline.alpha = 1.0;
} completion:^(BOOL finished) {
if (finished) {
[UIView animateWithDuration:0.25 animations:^{
self.content.alpha = 1.0;
}];
}
}];
}
}];
}
}];
}
}];

为了实现动画序列,需要在前一个动画的 completion 块中新建下一个动画,当动画比较多的时候,嵌套层数能把人看醉了,维护起来各种蛋疼。作为一个有重度代码洁癖的程(处)序(女)员(座),怎么能够忍受这样的代码?所以就想实现一种动画的容器,将嵌套关系转换成并列关系,利用递归来封装嵌套操作,将动画序列用简单清晰的方式管理起来。

2. 设计思路

在实现之前,按照经验,先去 Github 上找一找有没有别人已经做好的实现,没必要重新造轮子,而且不一定造出来的有别人的好(多么痛的领悟)。既然是开源,把别人的看懂了不就相当于自己的嘛。而且,一般你想到的自己觉得很犀利的点子说不定人家已经做烂了(多么痛的领悟x2)。果然,找到了下面两个库,于是我读了下他们的源码,分析如下:

  • UIKitAnimationPro:将动画抽象成Action,用类似Spirit Kit的样式提供动画序列,思路很棒,但是问题在于:
    • 思路是为某一个UIView指定动画序列,这样不是特别合理,因为动画序列很有可能包含多个UIView的动画。
    • 虽然将rotate,scale等操作封装起来方便使用,但是还是不够灵活,应该提供options的接口。
    • 只能串行,没有并行。
  • CPAnimationSequence:没有上述的缺点,已经是非常全面的实现了。但是在读源码的过程中发现里面使用了组合(Composite)模式,但是并没有用好。基类不是一个接口类(Component),而是一个具体的节点类(Leaf),容器类(Composite)继承节点类。这里不是教条主义,一定要按照设计模式要求的来,而是有以下的问题:
    • 由于Composite继承了Leaf,导致Composite中从Leaf继承下来一些跟Composite无关的数据,只好使用NSAssert来确保外部不会使用,这是不合理的。
    • 并没有将组合模式的优点发挥出来,导致代码不够清晰。

基于以上原因,我决定实现一个新的动画序列容器 SCRAnimationChain,来解决上面所说的问题。为什么前缀是 SCR 呢?因为是我名字(尚传人)拼音首字母的大写,而且根据《Effective Objective-C》的建议,由于苹果宣称保留使用所有两字母前缀的权利,为了避免以后可能跟系统库的前缀重名,所以最好使用三个字母前缀。

思路如下:

  1. 由于动画的动作被封装在块中,所以将动画抽象成 Action 类(封装 Block),并且包含 delay、duration、options 等属性,用来保持最大的灵活性。
  2. 参考 GCD 的思路,实现 Sequence 和 Concurrent 两种 Container 类,分别用来针对串行动画跟并行动画的需求。
  3. 由于 Container 与 Action,类似文件目录与文件的关系,所以用组合模式抽象出一个接口类(Protocol)来处理他们之间的关系。这样的好处就是只要实现了接口类,不管具体是什么都可以放置到 Container 中,灵活性跟可扩展性都很强。

3. 实现

项目源码在这里

SCRAnimationActionProtocol

接口类,相当于组合模式中的 Component,在 Objective-C 中用协议描述。

  • (void)runWithCompletion:(SCRAnimationCompletionBlock)completion:动画的核心就是运行,而为了能够形成链,所以增加了 completion 参数用来形成链。
  • (NSTimeInterval)workTime:在并行动画中需要找到最终结束时间最长的那个动画,这样才能在并行动画中将动画链传递下去。
  • (void)addAction:(id<SCRAnimationActionProtocol>)action:用来供容器添加动画 Action。

SCRAnimationAction

封装具体的动画,相当于组合模式中的 Leaf,里面的属性基本上是为了在调用 animateWithDuration:delay:options:animations:completion: 的时候用。

这里主要说明下 prepare 属性的用途。由于动画形成了序列,某个动画在执行时有可能前面的动画执行的一些操作会影响到当前动画,因此需要在执行此动画之前提供一个接口供其配置各种信息。

例如,在使用 AutoLayout 之后不能直接 frame 了,而是要使用constraint,在动画的块中调用 constraint 的 superview(注意不能是 constraint 依附的 view)的 layoutIfNeeded。在动画序列开始前更新constraint 的话,有可能前面的动画提前调用了 layoutIfNeeded,导致动画出问题。(在 Demo 的 ViewController 中,你可以试试将那段在 prepare 块中的代码移到块之外,看会出现什么情况)

SCRAnimationContainer

由于 SCRAnimationSequence 跟 SCRAnimationConcurrence 内部都有一个 NSMutableArray 来保存 Action,所以将重复的代码抽出来,形成两者的基类。总之,DRY(Don’t Repeat Yourself)!

SCRAnimationSequence

串行动画的容器。利用递归的方式实现串行执行:在 runWithCompletion: 中每次从内部数组中取出一个项(由于是 id<SCRAnimationActionProtocol> 类型,所以可以是SCRAnimationAction,SCRAnimationSequence,SCRAnimationConcurrence 或者实现 SCRAnimationActionProtocol 协议的任意类,这就是基于接口编程跟组合模式带来的灵活性),对其调用 runWithCompletion:,在 completion 块中再次调用本身来实现递归。

SCRAnimationConcurrence

并行动画的容器。直接用 for 循环,对内部数组的每一个 Action 调用 runWithWithCompletion:,找出并行动画中 workTime(delay + duration)最大的那个动画,将 SCRAnimationConcurrence 的 completion 块交给这个动画,确保 completion 能够得到执行。

现在 App 类似桌面软件的趋势,在功能完善之后,渐渐追求个性化,以满足不同用户的审美,主题更换就是其中一项,像 Weico 微博客户端,UC 浏览器。所谓主题,可以看成相同功能不同展现可视资源的集合,例如,按钮无论在什么主题下都需要背景图片这个资源,只是在不同主题下是不同的背景图片而已。

如何在 iOS 中实现主题更换的核心思路为:

  1. 资源按主题放置:相同功能的资源名称相同,放在不同的主题路径或者前缀使用主题名。
  2. 增加中间层,隔离不同主题相同功能资源使用的变化。

1. 主题管理

主题的特性导致代码不关心资源的表现是什么,只关心资源的功能,而主题是易变化的,因此需要将易变化的部分抽离出来,整合到一个管理者中,主题的变化在管理者中完成,而不影响资源使用的地方。而且这个管理者是全局唯一的,因此使用单例。

1
2
3
4
5
6
7
+ (ThemeManager *)sharedInstance {
static ThemeManager *sharedInstance = nil;
if (sharedInstance == nil) {
sharedInstance = [[ThemeManager alloc] init];
}
return sharedInstance;
}

主题中的资源使用 plist 进行存储,颜色的 RGBA 值跟字体的信息可以直接存入 plist,而图片则可以存入图片的位置。按主题命名 plist 文件,ThemeManager 的初始化跟主题更换就从 Main Bundle 中按主题名字读取对应的 plist 文件。

1
2
3
4
5
6
7
8
9
10
- (id)init {
if (self = [super init]) {
NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults];
NSString *themeName = [defaults objectForKey:@"theme"] ?: @"default";

NSString *path = [[NSBundle mainBundle] pathForResource:themeName ofType:@"plist"];
self.theme = [NSDictionary dictionaryWithContentsOfFile:path];
}
return self;
}

代码中不再是直接使用主题相关的资源,而是通过 ThemeManager 得到对应主题下的资源。

1
2
3
4
5
6
7
// 直接使用资源:
UIImage *image = [UIImage imageNamed:@"xxx_btn_background"];

// 通过主题管理器使用资源:
NSDictionary *theme = [ThemeManager sharedInstance].theme;
NSString *imageName = [theme objectForKey:@"xxx_btn_background"];
UIImage *image = [UIImage imageNamed:imageName];

上面的代码在使用时还是有些复杂,代码只关心资源的功能,不关系也不应该关心取资源的细节,因此应在 ThemeManager 对取资源进行如下封装:

1
- (UIImage *)imageForKey:(NSString *)key;

在使用主题中的资源时,代码就变成了:

1
UIImage *image = [[ThemeManager sharedInstance] imageForKey:@"xxx_btn_background"];

2. 资源的放置

当系统将主题相关的资源文件部署到 iOS 设备中时,在默认情况下,系统会将所有的资源** plat 平铺**拷贝到 Main Bundle 目录下,即使你的资源是按文件夹来组织的。(我们可以在模拟器中查看 Bundle 的情况,模拟器的路径是:~/Library/Application Support/iPhone Simulator

因此,在将资源文件加入到工程时,不要选默认的 “Recursively create groups for any add folders”,要选择 **”Create Folder Reference for any add folders”**,这样才能保证资源文件按照原有文件夹的组织格式被拷贝到 Main Bundle 中。

关于上述的两个选项,就涉及到 Xcode 的 Group(黄色)跟 Folder Reference(蓝色)的概念了,参见从别处摘抄来的理解:

XCode 项目中的文件夹分成两类: group 和 directory reference, 分别是虚结构和实结构. 黄颜色的 group 是默认的格式, 它的结构和磁盘上的文件夹毫无关系, 仅仅表示资源的逻辑组织结构, 这在管理源文件是非常方便. 同一段代码可以被很多项目使用, 也可能只使用一个目录的部分文件, 它不需要被拷贝到当前项目中, 但可以在当前项目中保持一个清晰的逻辑结构. 而且引用头文件时不需要指明复杂的层次结构, 因为这些文件在XCode看来是 flat 的, 即它们处在同一层文件夹里.
但是 group 带来便利的同时也导致更加棘手的麻烦, 文件重名冲突问题; 尤其当你要使用上千个资源文件时, 这种问题已经极难避免; 而且, 资源文件一般是要拷贝到目标程序中的, 虽然它们在项目中可以有结构的组织, 但是复制到程序中时将会 flat 地输出到程序的根目录中, 这将是怎样的一个灾难! 同时, 如果你在外部向文件夹中加入了上百幅图片, 你不得不把它们再向xcode中加入一遍. 归根结底, 还要求助于我们传统的蓝色的 directory reference。

3. 主题更换通知

对于没有显示的界面,更换主题是不需要通知的,因为在取资源时是根据当前主题取的,但是对于正在显示的界面,更换主题时就需要进行通知,让界面重新取资源后再重绘。由于这类通知是全局性的,因此应该使用 NSNotification 实现通知机制。

在 ThemeManager 的 changeTheme 中调用 [NSNotificaitonCenter defaultCenter]postNotificationName:object: 发出通知,而在各个涉及到主题更换的 ViewController中 使用 addObserver:selector:name:object: 监听通知事件。

4. 总结

其实主题的设计思路跟类簇很像,例如对于 NSNumber,不同类型的数据其实真正返回的是 NSNumber 相对于此类型的子类,但是对于 NSNumber 的使用者而言,其并不关心 NSNumber 返回的具体子类是什么,只要满足 NSNumber 定义的接口就行。设计总是类似的,针对易变化的部分,增加一个中间层(接口)将易变化的部分封装起来,提供给使用者稳定不易变的服务。

总之,OOP 跟 Design Pattern 在我看来主要做了两件事:

  1. 隔离变化
  2. 封装细节

参考

CALayer 拥有 mask 属性,Apple 的官方解释如下:

An optional layer whose alpha channel is used to mask the layer’s content. The layer’s alpha channel determines how much of the layer’s content and background shows through. Fully or partially opaque pixels allow the underlying content to show through but fully transparent pixels block that content.

mask 同样也是一个 CALayer。假设将 CALayer 本身称为 ContentLayer,将mask 称为 MaskLayer,蒙版(Masking)的工作原理是通过 MaskLayer 的 alpha 值定义 ContentLayer 的显示区域:对于 ContentLayer 上每一个 Point,计算公式为 ResultLayer = ContentLayer * MaskLayer_Alpha。所以当 alpha 为 1 时 Content 显示,alpha 为 0 时 Content 不显示,其他处于 0 与 1 之间的值导致 Content 半透明。

需要注意的是:

  1. MaskLayer 的 color 不重要,主要使用 opacity(CALayer 中的 alpha),但是注意 [UIColor clearColor] 其实就是 alpha 为 0 的 color。
  2. ContentLayer 超出 MaskLayer 以外的部分不会被显示出来。
  3. MaskLayer 必须是个“单身汉”,不能有 sublayers,否则蒙版(Masking)的结果就是未知(Undefined)。

由于 mask 是一个 CALayer,可以通过组合产生很多非常棒的效果。例如可以将 MaskLayer 指定为 CAGradientLayer 类型实现 Gradient 效果,可以给 MaskLayer 添加动画,下面两个例子就是这种用法的经典实例:

对于 UIView 的 frameboundscenter 都比较熟悉:

  • bounds指定 UIView 自身坐标系。
  • center指定 UIView 在 superview 中的位置坐标。
  • frame由互不影响(正交)的 boundscenter 生成。

但是我一直有这样的疑问:为什么要使用 UIView 中心位置的 center 来指定坐标,为什么不用其他位置?

后来学习了 CALayer,发现 UIView 的主要任务其实是响应事件(这就是为什么从 UIResponser 继承的原因),而将显示委托给 CALayer 处理。一个成功的 UIView 背后总有一个默默贡献的 CALayer,CALayer 作为 GPU/OpenGL 纹理的一种高层封装,处理显示的方方面面,UIView 则将 CALayer 一些功能再次封装,提供简洁好用的接口。像 UIView 中的 framebounds 就是直接使用 CALayer 中的 framebounds

但是对于 center,CALayer 对应项是 position,为什么不同名了呢?因为 position 更灵活,谁规定“指定 UIView 在 superview 中的位置坐标”一定要在 UIView 的中心位置呢?!而 position 默认在中心位置的目的我觉得是为了方便 Rotation,因为一般 Rotation 都是绕着中心旋转,UIView 为了简化使用,满足大部分情况即可,所以就将默认在中心的 position 封装成了 center

由于 CALayer 的 position 并没有限制一定要在 bounds 的中心位置,所以就需要一个属性来描述 positionbounds 中的位置,这样才能推算出 frame 的 origin 点位置。于是 anchorPoint 出现了,CALayer 用 anchorPoint 指定 positionbounds 中的位置,有如下特点:

  • 为了可以任意指定位置,因此 anchorPoint 就不使用绝对值,而是比例值。
  • 由于 Rotation 总是围绕 position 旋转,因此指定 position 在 Layer 中位置的 anchorPoint 就被命名为锚点(像船的锚一样固定位置)。

总之,frame 是由 positionboundsanchorPoint 共同生成(其实还有 transform,这就需要了解 Current Transform Matrix 的概念,为了减少复杂性,这里就不涉及了),公式如下:

  • frame.origin.x = position.x - bounds.size.width * anchorPoint.x
  • frame.origin.y = position.y - bounds.size.height * anchorPoint.y

这就解释了:

  1. 为什么 anchorPoint 改变会导致 frame 改变,而 position 却没变?
  2. 为什么 anchorPoint 要使用比例而不是具体值?

豁然开朗。

PS. 多思考,保持好奇心,多从设计者角度想想为什么这样设计,即使是简单的概念,也会有收获。Stay Hungry,Stay Foolish。