WeRead团队博客


  • 首页

  • 归档

NSAttributedString的autorelease内存风暴

发表于 2020-11-11   |   作者: jasenhuang   |  

前言

  微信读书iOS客户端也有几年历史了,和人一样,项目代码老了或多或少总有一些毛病很难根治。微信读书iOS客户端就有一个这样的历史代码模块:阅读器排版引擎。想来这个引擎代码还是和 @bang哥 共事年代的代码。基于DTCoreText的HTML树排版引擎,总体思路就是把HTML文本通过libxml2解析,再用DFS遍历树结点,处理CSS后转成NSAttributedString,最终利用CoreText渲染出来,当然少不了在源码基础上,针对业务进行各种魔改。

顽疾

  回到微信读书排版引擎的问题,经常有微信读书的线上用户反馈过来,阅读的时候经常卡死闪退,但是我们的内部监控系统(bugly/matrix)一直没有抓到crash堆栈上报。这就很神奇了,根据以往经验,没有crash堆栈上报很大概率是被系统的WatchDog强杀了,一般是OOM或者wakelock太频繁。针对用户反馈的日志定位到相应的场景,发现有一个共性:HTML里<p>标签特别多,具体用户的场景里是遇到单个章节有2w+的<p>标签,大胆盲猜,这里是OOM了,果然用instruments一跑,发现了有个神奇的东西:


Allocation Summary里多了很多@autoreleasepool content,每一个占去了4k的内存,App的内存在短时间内暴涨到1个G以上,触发系统WatchDog强杀。

定位原因

  这里大小为4k@autoreleasepool content是怎么产生的?而且NSAttributedString在整个iOS开发生态里使用非常多,UIKit系统控件都会用到,为什么单单这个场景会引发这个问题? 难道又是打开方式不对?长文预警,想看结论的同学可以直接跳到后面

我们带着这两个疑问继续往下分析,我们先来看下调用链:

  1. DFS遍历树结点[DTHtmlElement attributeString]
  2. 在<p>标签结束处理段落appendEndOfParagraph,代码可以简化为:

    1
    2
    3
    4
    5
    6
    7
    8
    - (void)appendEndOfParagraph {
    ....
    // create a temp attributed string from the appended part
    NSAttributedString *appendString =
    [[NSAttributedString alloc] initWithString:@"\n" attributes:attributes];
    [self appendAttributedString:appendString];
    }
  3. 生成NSAttributedString对象,并设置属性attributes

  4. NSConcreteHashTable触发autoreleaseFullPage去申请内存_malloc_zone_memalign

单纯看项目代码,是很普通的函数调用,为什么会用到HashTable,HashTable又为什么会触发autoreleaseFullPage?autorelease pool没有及时释放吗?
关于autorelease,网上有很多文章,大家可以自行google或参考文章最后的文章链接,基本结论可以归结为:

  1. autorelease对象什么时候产生?

    • 以alloc/new/copy/mutableCopy开头的函数调用,编译器会自动插入release语句,否则返回的对象会加到autorelease池子中
    • 函数调用会根据objc_autoreleaseReturnValue和objc_retainAutoreleasedReturnValue进行TLS优化判断,避免autorelease过多
  2. autorelease对象什么时候释放?

    • @autoreleasepool{} -> 退出作用域的时候
    • NSThread -> 线程退出调用tls_dealloc的时候
    • GCD -> 处理完队列任务后调用 _dispatch_last_resort_autorelease_pool_pop

不过GCD任务存在多线程切换的时机问题,释放时机有随机性,按照Apple文档的说法:

If your block creates more than a few Objective-C objects, you might want to enclose parts of your block’s code in an @autorelease block to handle the memory management for those objects. Although GCD dispatch queues have their own autorelease pools, they make no guarantees as to when those pools are drained. If your application is memory constrained, creating your own autorelease pool allows you to free up the memory for autoreleased objects at more regular intervals.
大概的意思就是GCD会自动添加autorelease池,但释放时机不能保证,创建大量对象时需要自行添加@autoreleasepool{}保证及时释放

回到微信读书排版引擎的代码实现本身,逻辑上讲并没有什么问题,可以简化为:

1
2
3
4
5
6
dispatch_group_async(queue, ^{
...
assembleString = [node attributeString];
[assembleString appendEndOfParagraph];
...
})

  那这里是不是加上@autoreleasepool{}就万事大吉了呢?结果一顿操作,内存是降下来了,但排版速度却严重变慢,看来还不是这么简单,这里先盗张图:

autorelease池子会不断pop对象并调用[obj release],查询共用的sizetable重新计算refCnt,refCnt为0的时候要调[obj dealloc],autorelease对象多的话这个过程还是挺耗时的。

  既然不能直接加@autoreleasepool{},那回过头来看,可不可以减少加到autorelease池里对象呢?首先我们要搞清楚这里加入aurelease池是哪些对象?为什么会产生大量的对象?从代码上看,NSSAttributeString直接调用alloc方法,返回对象经过TLS优化也不存在加入aurelease池的情况(可以在xcode里通过Debug->DebugWorkflow->Disassembly查看汇编代码,再用LLDB进去看TLS优化生效了没有)没办法只能祭出最后的武器:汇编

  通过反编译Foundation/UIFoundation/libobjc.A.dylib,我们大概理清了一下NSAttributedString的初始化过程:

  1. NSConcreteAttributedString初始化

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    int -[NSConcreteAttributedString initWithString:attributes:]() {
    r14 = rcx;
    rbx = [rdi initWithString:rdx];
    if ((r14 != 0x0) && (rbx != 0x0)) {
    r15 = [[NSMutableRLEArray allocWithZone:[rbx zone]] init];
    r14 = [__NSAttributeDictionaryClass() newWithDictionary:r14]; //r14 是传入属性NSDictionary
    [rbx length];
    [r15 insertObject:r14 range:0x0];
    [r14 release];
    rbx->attributes = r15;//r15 是 NSMutableRLEArray
    }
    rax = rbx;
    return rax;
    }
  2. NSDictionary -> NSAttributeDictionary过程可以简化为:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    + (NSAttributeDictionary*)newWithDictionary:(NSDictionary*)dict {
    if (!dict) {
    return _emptyAttributeDictionary;
    }
    os_unfair_lock_lock_with_options(_attributeDictionaryLock, 0x10000);
    NSAttributeDictionary* rax = [_attributeDictionaryTable getItem:dict];//NSConcreteHashTable
    if (!rax){
    os_unfair_lock_unlock(_attributeDictionaryLock);
    ...
    NSZoneMalloc();//找不到重新new一个
    ...
    [dict getObjects:andKeys:count:];// 遍历dict取出kv对,copy到NSAttributeDictionary
    ...
    }else{
    [rax retain];//找到直接返回对象复用
    os_unfair_lock_unlock(_attributeDictionaryLock);
    }
    return rax;
    }

_attributeDictionaryTable是全局共用的NSConcreteHashTable,操作的时候需要加上os_unfair_lock,NSAttributeDictionary存放在NSConcreteHashTable,看逻辑应该是为了在初始化NSAttributedString的attributes的时候进行对象复用。

  1. NSConcreteHashTable如何查找NSDictionary

    1
    2
    3
    4
    5
    6
    - (void)getItem:(NSDictionary*)dict {
    if (dict != 0x0) {
    rax = _hashProbe(dict);//关键函数
    }
    return rax;
    }
  2. hashProbe函数简化版:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    id hashProbe(NSDictionary* dict) {
    void* location = attributeDictionaryHash(dict);// hash函数:遍历kv值进行hash累加
    do {
    ...
    location = (location + 1) & (capicity - 1);// hash值偏移0x1 线性探测
    obj = readARCWeakAtWithSentinel(location, 0);
    if (!obj){
    break; // hash桶的位置数据为空
    }
    } while (!isEqualFuntion(obj, dict));
    return obj;
    }
  3. readARCWeakAtWithSentinel从HashTable里取出NSAttributeDictionay

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    id readARCWeakAtWithSentinel(void* location, int sentinel){
    ...
    rax = objc_loadWeak(locaction);// 关键函数
    return rax;
    }
    id objc_loadWeak(void* location){
    if (!*location) return nil;
    return objc_autorelease(objc_loadWeakRetained(location));//autorelease在这里
    }

看到这里终于理清了这些autorelease对象是哪里产生: NSConcreteHashTable进行hash探测的时候,会不断读出hash值所在location的对象,放到autorelease池并进行isEqualFunction比较,如果不相等,hash值会先偏移0x1继续while查找。问题就在这里:如果NSConcreteHashTable里存了大量的对象,那这个while过程会不断产生autorelease对象,造成AutoreleasePoolPage::autoreleaseFullPage不断重新申请4k的内存

实践出真理,我们直接fishhook相应的函数来验证:

1
2
3
4
5
6
7
8
NSUInteger attributeDictionaryCount = 0;
static id (*orig_objc_loadWeak)(id *location);
id my_objc_loadWeak(id *location) {
if ([(*location) isKindOfClass:NSClassFromString(@"NSAttributeDictionary")]){
++attributeDictionaryCount;// 计数
}
return orig_objc_loadWeak(location);
}

排版一个有2w个<p>标签的HTML章节,objc_loadWeak会加载6747w个NSAttributeDictionary对象

解决方案

  到此,NSAttributedString内存暴涨的原因算是找到了,解决方案其实很简单:就是如何避免hash冲突。这里有两个前提:

  1. 直接去改NSDictionary的hash函数有点困难
  2. 加@autoreleasepool{} 影响效率

其实从场景上分析,HTML的<p>标签是有限个(实际场景中超1w个的都是极少数),而且<p>对应的attributes里只保存的对象的pointer,内存占用大小也只是NSAttributeDictionay本身,attributes复用的意义并不是很大。这里直接在attributes里加上一个 随机因子,减少 hashProbe 的命中数,NSConcreteHashTable没命中会直接跳过objc_loadWeak的调用,也就不会产生autorelease对象了。

1
2
3
4
5
6
7
8
9
10
- (void)appendEndOfParagraph {
....
// create a temp attributed string from the appended part
NSAttributedString *appendString =
[[NSAttributedString alloc] initWithString:@"\n" attributes:attributes];
[attributes setObject:@(arc4random()) forKey:@"random"];//随机因子
[self appendAttributedString:appendString];
}

优化后解析同一个HTML章节,objc_loadWeak只加载了 27w个NSAttributeDictionary对象

当然这种解决方案并不是最好的,只是衡量投入产出比后折中的方案,如果有更好的解决方案,欢迎私信交流!

最后打个广告:微信读书iOS/Android客户端大量招人,欢迎大家加入
简历直接发:jasenhuang@rdgz.org

友情链接

  1. 黑幕背后的Autorelease
  2. 自动释放池的前世今生
  3. Autorelease 之不经意间可能被影响的优化
  4. Revisit iOS Autorelease

Objective-C单向数据流方案

发表于 2017-09-30   |   作者: zepo   |  

背景

当我们在写Objective-C代码时,会习惯性地把model对象的属性定义为nonatomic。如果该属性是被多线程访问的,那么这样做是有可能crash的。我们可以简单地模拟一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Conversation.h
@interface Conversation : NSObject
@property (nonatomic, copy) NSArray *messages;
@end
// xxx.m
- (void)methodA
{
Conversation *conversation = [[Conversation alloc] init];
dispatch_async(dispatch_get_global_queue(0, 0), ^{
while (1) {
conversation.messages = [[NSArray alloc] initWithObjects:@1, @2, @3, nil];
}
});
dispatch_async(dispatch_get_global_queue(0, 0), ^{
while (1) {
NSLog(@"%@", conversation.messages);
}
});
}

调用上面的methodA方法,程序在运行时会抛出EXC_BAD_ACCESS异常。

如果把属性定义为atomic,我们能避免上面的crash,但仍然存在其他多线程带来的问题。比如竞争条件(race condition)问题,数据一致性问题等等。另外,由于属性是可变的,我们可以在程序的任意地方修改该属性,如果该属性作为某个页面的展示数据,那么我们需要在所有修改的地方发出通知以刷新页面。如果该属性对应的是UITableView的cells,修改该属性而没有通知到UITableView做reloadData的话同样会导致crash。

随着app的不断发展而变得复杂,修改同一个属性的地方会不断增多,所有这些修改和通知会变得很难维护。一旦出现问题,我们也很难找到修改数据的源头,调试这类bug的成本变得很高。

Flux & Redux

对于上面的问题,在js界已经有很成熟的解决方案:Flux和Redux。这两者通过各自的编程规范,来避免上面的数据可变带来的问题。我们以Redux为例,来做具体的分析。下面的分析主要是对Redux官方文档的复述,会有些繁琐,如果你对Redux已经非常熟悉,可跳过该部分。Redux主要有以下几个部分:



Store

整个app只有一个store,且app的所有数据以dictionary的形式存在该store里。以Redux官网的todo app为例,整个app的数据主要分为两块,todo列表todos和过滤器visibilityFilter:

1
2
3
4
5
6
7
8
9
10
{
todos: [{
text: 'Eat food',
completed: true
}, {
text: 'Exercise',
completed: false
}],
visibilityFilter: 'SHOW_COMPLETED'
}

Action

对于store里的数据,外部是不能直接修改的。所有数据的修改都必须通过store提供的dispatch接口,传进一个action,在store内部进行。Action是对修改操作的描述:

1
2
3
{ type: 'ADD_TODO', text: 'Go to swimming pool' }
{ type: 'TOGGLE_TODO', index: 1 }
{ type: 'SET_VISIBILITY_FILTER', filter: 'SHOW_ALL' }

用action来描述所有修改操作有很多好处,比如我们可以很方便地记录所有修改以便调试。如果记录了初始状态和所有actions我们也可以很方便地实现回放,撤消(undo)等功能。

Reducer

Store在接收到action后,会通过reducer来修改内部的状态。Reducer只是一些普通的纯函数,输入初始状态和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
28
29
30
function visibilityFilter(state = 'SHOW_ALL', action) {
if (action.type === 'SET_VISIBILITY_FILTER') {
return action.filter
} else {
return state
}
}
function todos(state = [], action) {
switch (action.type) {
case 'ADD_TODO':
return state.concat([{ text: action.text, completed: false }])
case 'TOGGLE_TODO':
return state.map(
(todo, index) =>
action.index === index
? { text: todo.text, completed: !todo.completed }
: todo
)
default:
return state
}
}
function todoApp(state = {}, action) {
return {
todos: todos(state.todos, action),
visibilityFilter: visibilityFilter(state.visibilityFilter, action)
}
}

注意reducer在执行时不会直接修改原数据,而是重新生成整棵状态树,model数据是不可变的。

我们可以看到,在Redux里数据的流向是单向的。数据只能从store流向view,而不能从view流向store。当在view上进行操作需要修改数据时,我们要用action来描述操作,然后把action传进store里,在store内部修改数据。这样,我们就把所有的修改都收拢到了store这一层。同时,我们也只需要在store这一层发出通知来刷新view,所有的通知也被收拢到了一个地方。这样就解决了我们前面提到的修改和通知很难维护的问题。

Objective-C的不适应性

我们可以把Redux这套方案直接应用到Objective-C上,但这样做存在几个问题:

Action的定义

JavaScript是弱类型语言,把action定义为dictionary是很自然的事。但在Objective-C里,如果我们把action定义为NSDictionary,就失去了强类型语言带来的好处。我们也可以为每一个action定义一个相应的类,但这样又会使开发变得很繁琐。通常,客户端app执行一个action操作是比较复杂的,涉及数据库操作和网络请求,大多数时候我们需要再抽出一个方法来执行action。这样同时定义action和定义执行action的方法会使开发变得很重复。

Store的存储

通常客户端app的数据是比较多,而且我们需要在app的多次启动间保存数据。因此,对于大多数客户端app,部分数据是存在磁盘的,我们不可能把所有数据以dictionary的形式存在内存。当数据存在磁盘时,我们也无法用类似reducer的纯函数来修改store的状态。

Reflow解决方案

Reflow参照了Redux的架构和规范,实现了Objective-C语言的单向数据流方案,同时解决了语言的不适应性问题。下面我们来具体的分析一下Reflow:

Store

与Redux类似,在Reflow里我们要求所有的数据都存在store这一层,且所有的修改和通知也收拢到store这一层。但在Reflow里,store是抽象的概念,store里的数据可以存在磁盘,也可以存在内存,也可以是两者的混合。Store这一层通过对外暴露getters接口以拿数据,暴露actions接口以修改数据。随着app的不断发展而变得复杂,我们可以把store划分成多个模块,每个模块都继承于RFStore:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@interface TodoStore : RFStore
#pragma mark - Getters
- (NSArray *)visibleTodos;
- (VisibilityFilter)visibilityFilter;
#pragma mark - Actions
- (void)actionAddTodo:(NSString *)text;
- (void)actionToggleTodo:(NSInteger)todoId;
- (void)actionSetVisibilityFilter:(VisibilityFilter)filter;
@end

Action

Action是定义在store上的普通方法,action的方法名都以action开头。Reflow会对所有以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
@implementation TodoStore
...
#pragma mark - Actions
- (void)actionAddTodo:(NSString *)text {
Todo *todo = ...
self.todos = [self.todos arrayByAddingObject:todo];
}
- (void)actionToggleTodo:(NSInteger)todoId {
self.todos = [self.todos map:^id(Todo *value) {
if (value.todoId == todoId) {
Todo *todo = ...
return todo;
}
return value;
}];
}
- (void)actionSetVisibilityFilter:(VisibilityFilter)filter {
self.filter = filter;
}
@end

在action方法里,我们只需做数据修改的任务,而不用去发通知以刷新UI。并且,在Reflow里,我们建议所有的数据修改都要生成新的model对象并替换,而不是直接修改原model对象的属性。

Subscriptions

继承RFStore后,所有store模块都有subscribe接口。我们可以通过该接口订阅发生在该store模块上的所有action操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@implementation TodoTableViewController
- (void)viewDidLoad {
[super viewDidLoad];
self.todoStore = [[TodoStore alloc] init];
self.todos = [self.todoStore visibleTodos];
self.filterButton.title = [self stringFromVisibilityFilter:[self.todoStore visibilityFilter]];
self.subscription = [self.todoStore subscribe:^(RFAction *action) {
if (action.selector == @selector(actionSetVisibilityFilter:)) {
self.filterButton.title = [self stringFromVisibilityFilter:[self.todoStore visibilityFilter]];
}
self.todos = [self.todoStore visibleTodos];
[self.tableView reloadData];
}];
}
...
@end

每当store模块上的action方法被调用后,该store模块会拼装一个RFAction对象,作为参数调用所有订阅的block。RFAction对象与Redux的action类似,包含了描述一个操作所需的信息:

1
2
3
4
5
6
7
@interface RFAction : NSObject
@property (nonatomic, readonly) id object;
@property (nonatomic, readonly) SEL selector;
@property (nonatomic, readonly) NSArray *arguments;
@end

我们也可以通过下面的方法订阅所有store模块的所有action,这样我们就可以记录app的所有修改以便调试,也可以很容易地实现回放操作,撤消操作等:

1
2
3
[RFStore subscribeToAllStores:^(RFAction *action) {
...
}];

上面的完整的例子可以参考Github上的Example。

总结

Reflow这个库相对比较小,代码量也很少。对于Reflow来说,更重要的是它的架构设计和规范:

  • model对象不可变
  • 整个app的数据存于store层
  • 更新和通知也收拢于store层

Reflow的设计参考了很多优秀的开源框架和文章,这里把它们列出来以供参考:

  • Flux
  • Redux
  • Vue
  • Building and managing iOS model objects with Remodel

iOS App 签名的原理

发表于 2017-03-13   |   作者: bang   |  

iOS 签名机制挺复杂,各种证书,Provisioning Profile,entitlements,CertificateSigningRequest,p12,AppID,概念一堆,也很容易出错,本文尝试从原理出发,一步步推出为什么会有这么多概念,希望能有助于理解 iOS App 签名的原理和流程。

目的

先来看看苹果的签名机制是为了做什么。在 iOS 出来之前,在主流操作系统(Mac/Windows/Linux)上开发和运行软件是不需要签名的,软件随便从哪里下载都能运行,导致平台对第三方软件难以控制,盗版流行。苹果希望解决这样的问题,在 iOS 平台对第三方 APP 有绝对的控制权,一定要保证每一个安装到 iOS 上的 APP 都是经过苹果官方允许的,怎样保证呢?就是通过签名机制。

非对称加密

通常我们说的签名就是数字签名,它是基于非对称加密算法实现的。对称加密是通过同一份密钥加密和解密数据,而非对称加密则有两份密钥,分别是公钥和私钥,用公钥加密的数据,要用私钥才能解密,用私钥加密的数据,要用公钥才能解密。

简单说一下常用的非对称加密算法 RSA 的数学原理,理解简单的数学原理,就可以理解非对称加密是怎么做到的,为什么会是安全的:

  1. 选两个质数 p 和 q,相乘得出一个大整数n,例如 p=61,q=53,n=pq=3233
  2. 选 1-n 间的随便一个质数 e,例如 e = 17
  3. 经过一系列数学公式,算出一个数字 d,满足:
    a. 通过 n 和 e 这两个数据一组数据进行数学运算后,可以通过 n 和 d 去反解运算,反过来也可以。
    b. 如果只知道 n 和 e,要推导出 d,需要知道 p 和 q,也就是要需要把 n 因数分解。

上述的 (n,e) 这两个数据在一起就是公钥,(n,d) 这两个数据就是私钥,满足用公钥加密,私钥解密,或反过来公钥加密,私钥解密,也满足在只暴露公钥(只知道 n 和 e)的情况下,要推导出私钥 (n,d),需要把大整数 n 因数分解。目前因数分解只能靠暴力穷举,而n数字越大,越难以用穷举计算出因数 p 和 q,也就越安全,当 n 大到二进制 1024 位或 2048 位时,以目前技术要破解几乎不可能,所以非常安全。

若对数字 d 是怎样计算出来的感兴趣,可以详读这两篇文章:RSA 算法原理(一)(二)

数字签名

现在知道了有非对称加密这东西,那数字签名是怎么回事呢?

数字签名的作用是我对某一份数据打个标记,表示我认可了这份数据(签了个名),然后我发送给其他人,其他人可以知道这份数据是经过我认证的,数据没有被篡改过。

有了上述非对称加密算法,就可以实现这个需求:

  1. 首先用一种算法,算出原始数据的摘要。需满足 a.若原始数据有任何变化,计算出来的摘要值都会变化。 b.摘要要够短。这里最常用的算法是MD5。
  2. 生成一份非对称加密的公钥和私钥,私钥我自己拿着,公钥公布出去。
  3. 对一份数据,算出摘要后,用私钥加密这个摘要,得到一份加密后的数据,称为原始数据的签名。把它跟原始数据一起发送给用户。
  4. 用户收到数据和签名后,用公钥解密得到摘要。同时用户用同样的算法计算原始数据的摘要,对比这里计算出来的摘要和用公钥解密签名得到的摘要是否相等,若相等则表示这份数据中途没有被篡改过,因为如果篡改过,摘要会变化。

之所以要有第一步计算摘要,是因为非对称加密的原理限制可加密的内容不能太大(不能大于上述 n 的位数,也就是一般不能大于 1024 位/ 2048 位),于是若要对任意大的数据签名,就需要改成对它的特征值签名,效果是一样的。

好了,有了非对称加密的基础,知道了数字签名是什么,怎样可以保证一份数据是经过某个地方认证的,来看看怎样通过数字签名的机制保证每一个安装到 iOS 上的 APP 都是经过苹果认证允许的。

最简单的签名

要实现这个需求很简单,最直接的方式,苹果官方生成一对公私钥,在 iOS 里内置一个公钥,私钥由苹果后台保存,我们传 App 上 AppStore 时,苹果后台用私钥对 APP 数据进行签名,iOS 系统下载这个 APP 后,用公钥验证这个签名,若签名正确,这个 APP 肯定是由苹果后台认证的,并且没有被修改过,也就达到了苹果的需求:保证安装的每一个 APP 都是经过苹果官方允许的。

如果我们 iOS 设备安装 APP 只有从 AppStore 下载这一种方式的话,这件事就结束了,没有任何复杂的东西,只有一个数字签名,非常简单地解决问题。

但实际上因为除了从 AppStore 下载,我们还可以有三种方式安装一个 App:

  1. 开发 App 时可以直接把开发中的应用安装进手机进行调试。
  2. In-House 企业内部分发,可以直接安装企业证书签名后的 APP。
  3. AD-Hoc 相当于企业分发的限制版,限制安装设备数量,较少用。

苹果要对用这三种方式安装的 App 进行控制,就有了新的需求,无法像上面这样简单了。

新的需求

我们先来看第一个,开发时安装APP,它有两个个需求:

  1. 安装包不需要传到苹果服务器,可以直接安装到手机上。如果你编译一个 APP 到手机前要先传到苹果服务器签名,这显然是不能接受的。
  2. 苹果必须对这里的安装有控制权,包括
    a.经过苹果允许才可以这样安装。
    b.不能被滥用导致非开发app也能被安装。

为了实现这些需求,iOS 签名的复杂度也就开始增加了。

苹果这里给出的方案是使用了双层签名,会比较绕,流程大概是这样的:

  1. 在你的 Mac 开发机器生成一对公私钥,这里称为公钥L,私钥L。L:Local
  2. 苹果自己有固定的一对公私钥,跟上面 AppStore 例子一样,私钥在苹果后台,公钥在每个 iOS 设备上。这里称为公钥A,私钥A。A:Apple
  3. 把公钥 L 传到苹果后台,用苹果后台里的私钥 A 去签名公钥 L。得到一份数据包含了公钥 L 以及其签名,把这份数据称为证书。
  4. 在开发时,编译完一个 APP 后,用本地的私钥 L 对这个 APP 进行签名,同时把第三步得到的证书一起打包进 APP 里,安装到手机上。
  5. 在安装时,iOS 系统取得证书,通过系统内置的公钥 A,去验证证书的数字签名是否正确。
  6. 验证证书后确保了公钥 L 是苹果认证过的,再用公钥 L 去验证 APP 的签名,这里就间接验证了这个 APP 安装行为是否经过苹果官方允许。(这里只验证安装行为,不验证APP 是否被改动,因为开发阶段 APP 内容总是不断变化的,苹果不需要管。)

加点东西

上述流程只解决了上面第一个需求,也就是需要经过苹果允许才可以安装,还未解决第二个避免被滥用的问题。怎么解决呢?苹果再加了两个限制,一是限制在苹果后台注册过的设备才可以安装,二是限制签名只能针对某一个具体的 APP。

怎么加的?在上述第三步,苹果用私钥 A 签名我们本地公钥 L 时,实际上除了签名公钥 L,还可以加上无限多数据,这些数据都可以保证是经过苹果官方认证的,不会有被篡改的可能。

可以想到把 允许安装的设备 ID 列表 和 App对应的 AppID 等数据,都在第三步这里跟公钥L一起组成证书,再用苹果私钥 A 对这个证书签名。在最后第 5 步验证时就可以拿到设备 ID 列表,判断当前设备是否符合要求。根据数字签名的原理,只要数字签名通过验证,第 5 步这里的设备 IDs / AppID / 公钥 L 就都是经过苹果认证的,无法被修改,苹果就可以限制可安装的设备和 APP,避免滥用。

最终流程

到这里这个证书已经变得很复杂了,有很多额外信息,实际上除了 设备 ID / AppID,还有其他信息也需要在这里用苹果签名,像这个 APP 里 iCloud / push / 后台运行 等权限苹果都想控制,苹果把这些权限开关统一称为 Entitlements,它也需要通过签名去授权。

实际上一个“证书”本来就有规定的格式规范,上面我们把各种额外信息塞入证书里是不合适的,于是苹果另外搞了个东西,叫 Provisioning Profile,一个 Provisioning Profile 里就包含了证书以及上述提到的所有额外信息,以及所有信息的签名。

所以整个流程稍微变一下,就变成这样了:

因为步骤有小变动,这里我们不辞啰嗦重新再列一遍整个流程:

  1. 在你的 Mac 开发机器生成一对公私钥,这里称为公钥L,私钥L。L:Local
  2. 苹果自己有固定的一对公私钥,跟上面 AppStore 例子一样,私钥在苹果后台,公钥在每个 iOS 设备上。这里称为公钥A,私钥A。A:Apple
  3. 把公钥 L 传到苹果后台,用苹果后台里的私钥 A 去签名公钥 L。得到一份数据包含了公钥 L 以及其签名,把这份数据称为证书。
  4. 在苹果后台申请 AppID,配置好设备 ID 列表和 APP 可使用的权限,再加上第③步的证书,组成的数据用私钥 A 签名,把数据和签名一起组成一个 Provisioning Profile 文件,下载到本地 Mac 开发机。
  5. 在开发时,编译完一个 APP 后,用本地的私钥 L 对这个 APP 进行签名,同时把第④步得到的 Provisioning Profile 文件打包进 APP 里,文件名为 embedded.mobileprovision,把 APP 安装到手机上。
  6. 在安装时,iOS 系统取得证书,通过系统内置的公钥 A,去验证 embedded.mobileprovision 的数字签名是否正确,里面的证书签名也会再验一遍。
  7. 确保了 embedded.mobileprovision 里的数据都是苹果授权以后,就可以取出里面的数据,做各种验证,包括用公钥 L 验证APP签名,验证设备 ID 是否在 ID 列表上,AppID 是否对应得上,权限开关是否跟 APP 里的 Entitlements 对应等。

开发者证书从签名到认证最终苹果采用的流程大致是这样,还有一些细节像证书有效期/证书类型等就不细说了。

概念和操作

上面的步骤对应到我们平常具体的操作和概念是这样的:

  1. 第 1 步对应的是 keychain 里的 “从证书颁发机构请求证书”,这里就本地生成了一堆公私钥,保存的 CertificateSigningRequest 就是公钥,私钥保存在本地电脑里。
  2. 第 2 步苹果处理,不用管。
  3. 第 3 步对应把 CertificateSigningRequest 传到苹果后台生成证书,并下载到本地。这时本地有两个证书,一个是第 1 步生成的,一个是这里下载回来的,keychain 会把这两个证书关联起来,因为他们公私钥是对应的,在XCode选择下载回来的证书时,实际上会找到 keychain 里对应的私钥去签名。这里私钥只有生成它的这台 Mac 有,如果别的 Mac 也要编译签名这个 App 怎么办?答案是把私钥导出给其他 Mac 用,在 keychain 里导出私钥,就会存成 .p12 文件,其他 Mac 打开后就导入了这个私钥。
  4. 第 4 步都是在苹果网站上操作,配置 AppID / 权限 / 设备等,最后下载 Provisioning Profile 文件。
  5. 第 5 步 XCode 会通过第 3 步下载回来的证书(存着公钥),在本地找到对应的私钥(第一步生成的),用本地私钥去签名 App,并把 Provisioning Profile 文件命名为 embedded.mobileprovision 一起打包进去。这里对 App 的签名数据保存分两部分,Mach-O 可执行文件会把签名直接写入这个文件里,其他资源文件则会保存在 _CodeSignature 目录下。

第 6 - 7 步的打包和验证都是 Xcode 和 iOS 系统自动做的事。

这里再总结一下这些概念:

  1. 证书:内容是公钥或私钥,由其他机构对其签名组成的数据包。
  2. Entitlements:包含了 App 权限开关列表。
  3. CertificateSigningRequest:本地公钥。
  4. p12:本地私钥,可以导入到其他电脑。
  5. Provisioning Profile:包含了 证书 / Entitlements 等数据,并由苹果后台私钥签名的数据包。

其他发布方式

前面以开发包为例子说了签名和验证的流程,另外两种方式 In-House 企业签名和 AD-Hoc 流程也是差不多的,只是企业签名不限制安装的设备数,另外需要用户在 iOS 系统设置上手动点击信任这个企业才能通过验证。

而 AppStore 的签名验证方式有些不一样,前面我们说到最简单的签名方式,苹果在后台直接用私钥签名 App 就可以了,实际上苹果确实是这样做的,如果去下载一个 AppStore 的安装包,会发现它里面是没有 embedded.mobileprovision 文件的,也就是它安装和启动的流程是不依赖这个文件,验证流程也就跟上述几种类型不一样了。

据猜测,因为上传到 AppStore 的包苹果会重新对内容加密,原来的本地私钥签名就没有用了,需要重新签名,从 AppStore 下载的包苹果也并不打算控制它的有效期,不需要内置一个 embedded.mobileprovision 去做校验,直接在苹果用后台的私钥重新签名,iOS 安装时用本地公钥验证 App 签名就可以了。

那为什么发布 AppStore 的包还是要跟开发版一样搞各种证书和 Provisioning Profile?猜测因为苹果想做统一管理,Provisioning Profile 里包含一些权限控制,AppID 的检验等,苹果不想在上传 AppStore 包时重新用另一种协议做一遍这些验证,就不如统一把这部分放在 Provisioning Profile 里,上传 AppStore 时只要用同样的流程验证这个 Provisioning Profile 是否合法就可以了。

所以 App 上传到 AppStore 后,就跟你的 证书 / Provisioning Profile 都没有关系了,无论他们是否过期或被废除,都不会影响 AppStore 上的安装包。

到这里 iOS 签名机制的原理和主流程大致说完了,希望能对理解苹果签名和排查日常签名问题有所帮助。

P.S.一些疑问

最后这里再提一下我关于签名流程的一些的疑问。

企业证书

企业证书签名因为限制少,在国内被广泛用于测试和盗版,fir.im / 蒲公英等测试平台都是通过企业证书分发,国内一些市场像 PP 助手,爱思助手,一部分安装手段也是通过企业证书重签名。通过企业证书签名安装的 App,启动时都会验证证书的有效期,并且不定期请求苹果服务器看证书是否被吊销,若已过期或被吊销,就会无法启动 App。对于这种助手的盗版安装手段,苹果想打击只能一个个吊销企业证书,并没有太好的办法。

这里我的疑问是,苹果做了那么多签名和验证机制去限制在 iOS 安装 App,为什么又要出这样一个限制很少的方式让盗版钻空子呢?若真的是企业用途不适合上 AppStore,也完全可以在 AppStore 开辟一个小的私密版块,还是通过 AppStore 去安装,就不会有这个问题了。

AppStore 加密

另一个问题是我们把 App 传上 AppStore 后,苹果会对 App 进行加密,导致 App 体积增大不少,这个加密实际上是没卵用的,只是让破解的人要多做一个步骤,运行 App 去内存 dump 出可执行文件而已,无论怎样加密,都可以用这种方式拿出加密前的可执行文件。所以为什么要做这样的加密呢?想不到有什么好处。

本地私钥

我们看到前面说的签名流程很绕很复杂,经常出现各种问题,像有 Provisioning Profile 文件但证书又不对,本地有公钥证书没对应私钥等情况,不理解原理的情况下会被绕晕,我的疑问是,这里为什么不能简化呢?还是以开发证书为例,为什么一定要用本地 Mac 生成的私钥去签名?苹果要的只是本地签名,私钥不一定是要本地生成的,苹果也可以自己生成一对公私钥给我们,放在 Provisioning Profile 里,我们用里面的私钥去加密就行了,这样就不会有 CertificateSigningRequest 和 p12 的概念,跟本地 keychain 没有关系,不需要关心证书,只要有 Provisioning Profile 就能签名,流程会减少,易用性会提高很多,同时苹果想要的控制一点都不会少,也没有什么安全问题,为什么不这样设计呢?

能想到的一个原因是 Provisioning Profile 在非 AppStore 安装时会打包进安装包,第三方拿到这个 Provisioning Profile 文件就能直接用起来给他自己的 App 签名了。但这种问题也挺好解决,只需要打包时去掉文件里的私钥就行了,所以仍不明白为什么这样设计。

微信读书 iOS 质量保证及性能监控

发表于 2016-12-12   |   作者: tower   |  

在实现需求的同时,能写出既优雅性能又高效的代码是每个开发者都在追求的目标,但是在实际开发中,随着每个版本需求的迭代,功能变得越来越复杂,加上开发者的意识不够或者一时疏忽,日渐复杂的工程很容易产生或多或少的问题。在使用微信读书的过程中,我们也碰到过app随机丢失动画、用户反馈app卡死、用户投诉看不了书籍等等的问题,这些问题都严重影响使用,也会降低产品口碑,因此我们开发了一些监控工具来解决这些问题,在这里总结和分享一下。

先来看看app的结构,如下图:

微信读书主要分为5层,由下至上分为:

  • 网络层:负责和服务器通讯,使用http协议获取数据。
  • 数据层:存储用户的数据,包括内存cache和sqlite db。
  • 业务层:包含各种业务逻辑,比如书籍下载、排版等。
  • UI数据层:负责提供UI层所需要的数据,UI只和这层打交道。
  • UI层:包括ViewController和View,处理用户的输入。

当UI需要数据来渲染时,会向UIDataSource获取数据,UIDataSource首先是通过Manager查询内存cache和db看有没有数据,有的话就会直接返回,同时也会发出网络请求和服务器同步数据,再使用数据层的接口存到数据库,最后回调给UI去重新获取本地数据来渲染。每一个层都是相互独立,用户遇到的每一个问题都对应着每一层,下面说说我们在每一层遇到了什么问题,以及做了什么工作来解决它们。

阅读全文 »

APP 缓存数据线程安全问题探讨

发表于 2016-11-22   |   作者: bang   |  

问题

一般一个 iOS APP 做的事就是:请求数据->保存数据->展示数据,一般用 Sqlite 作为持久存储层,保存从网络拉取的数据,下次读取可以直接从 Sqlite DB 读取。我们先忽略从网络请求数据这一环节,假设数据已经保存在 DB 里,那我们要做的事就是,ViewController 从 DB 取数据,再传给 view 渲染:

这是最简单的情况,随着程序变复杂,多个 ViewController 都要向 DB 取数据,ViewController本身也会因为数据变化重新去 DB 取数据,会有两个问题:

  • 数据每次有变动,ViewController 都要重新去DB读取,做 IO 操作。

  • 多个 ViewController 之间可能会共用数据,例如同一份数据,本来在 Controller1 已经从 DB 取出来了,在 Controller2 要使用得重新去 DB 读取,浪费 IO。

阅读全文 »

基于 UIWebView 的富文本编辑器实践

发表于 2016-09-21   |   作者: zach   |  

背景

最近我们微信读书将写想法换成了基于 webview 的富文本编辑器,遇到了不少问题,这里我将简单的介绍一下我们在开发过程中踩到的坑。

实现富文本编辑器有两个基本思路:

  1. 基于 Native 实现:比如 CoreText 或者 TextKit
  2. 基于 UIWebView 实现

第一种方案,你需要自己去实现很多在 webview 已经很成熟的效果,比如链接,字体加粗,标题,引用样式,列表样式等等,这些的工作量都比较可观,而且还有 iOS / Android 两端的对齐问题。还有一个问题,这个可能是我们项目相关的问题,我们在原来还没有很多富文本要求的情况下,在 textView 上做了一些我们对链接的处理工作,仅仅这一个方面,当时就觉得不是很方便。

第二种方案,你可以借助 webview 省掉很多在第一种方案里面提到的工作,同时webview相对而言,开源的可供参考的项目也更多一点,不过 webview 也会存在光标的控制,css 的冲突处理以及兼容性的问题,不过在最终选择方案的时候,我们几经权衡,最终选择了 webview 的方案。

阅读全文 »

微信读书排版引擎自动化测试方案

发表于 2016-08-23   |   作者: rich   |  

引言

在 微信读书 App 中,排版引擎负责解析 EPUB 或 TXT 格式的书籍源文件,将排版后的书籍内容如文字、图像、注解等元素渲染至屏幕上,是最常用、最复杂的组件之一。

而开发同学对排版引擎的日常修改,可能影响了海量书籍的排版结果。对排版引擎代码变更的测试,往往耗时多、难度大、容易漏测。本文介绍了为解决测试的难题,如何逐步将人工测试步骤自动化,最终构建了一套微信读书排版引擎自动化测试流程,以确保微信读书排版引擎的质量。

背景

排版引擎日常修改

为了获得极致的阅读体验,产品同学经常会提出细致的排版需求,交给开发同学修改。而排版引擎的修改,往往牵一发动全身,可能导致书城上万本书籍排版结果受影响。

举个例子,有个需求是增加正文段落的 margin:

日常修改1

阅读全文 »

SQLite线程模式探讨

发表于 2016-08-19   |   作者: zepo   |  

背景

最近微信 iOS 团队发表了一篇文章《微信iOS SQLite源码优化实践》,该文章介绍了微信 iOS 客户端对 SQLite 进行的源码层级的优化,以及其所取得的成果。优化点包括:

  1. 多线程并发优化(Busy Retry 的优化)
  2. I/O 性能优化(保留 WAL 文件大小,mmap 优化)
  3. 其他优化(禁用文件锁,禁用内存统计锁)

其中,单是禁用内存统计锁这点优化,就取得了非常显著的效果,这里引用原文:

该优化上线后,卡顿监控系统监测到

  • DB写操作造成的卡顿下降超过80%
  • DB读操作造成的卡顿下降超过85%
阅读全文 »

Android Patch 方案与持续交付

发表于 2016-07-26   |   作者: mason   |  

Android 不仅系统版本众多,机型众多,而且各个市场都各有各的政策和审核速度,每次发布一个版本对于开发同学来讲都是一种漫长的煎熬。相比于 iOS 两三天就能达到 80% 的覆盖速度而言,Android 应用版本升级至少需要两周才能达到 80% 的升级率,严重阻碍了版本迭代速度。也导致市场上 App 版本分散,处理 bug 和投诉等也越来越麻烦。

  • 修复的 bug 需要等待下个版本发布窗口才能发布?
  • 已经 ready 的需求排队上线,需要等待其他 Feature Team 合入代码?
  • 老版本升级速度慢?频繁上线版本提醒用户升级,影响用户体验?

这几个问题是每个 App 开发同学都必然要面对的。那么有没有方法能在用户无感知的情况下加速 bug 处理和版本迭代速度?

在这方面 PC 端 Chrome 浏览器的 patch 升级方案给我们了一个很好的借鉴:当 Chrome 有版本升级的时候会自动下载 patch 文件。下次启动后,Chrome 就已经是新版本。

阅读全文 »

MLeaksFinder 新特性

发表于 2016-07-20   |   作者: zepo   |  

MLeaksFinder 是 iOS 平台的自动内存泄漏检测工具,引进 MLeaksFinder 后,就可以在日常的开发,调试业务逻辑的过程中自动地发现并警告内存泄漏。开发者无需打开 instrument 等工具,也无需为了找内存泄漏而去跑额外的流程。并且,由于开发者是在修改代码之后一跑业务逻辑就能发现内存泄漏的,这使得开发者能很快地意识到是哪里的代码写得问题。这种及时的内存泄漏的发现在很大的程度上降低了修复内存泄漏的成本。

MLeaksFinder 0.1 开源已经有一段时间,关于 MLeaksFinder 的基本原理,可以参考这篇文章。在 MLeaksFinder 开源之后,收到的最多的反馈是:MLeaksFinder 帮忙发现了内存泄漏,但是要去修复这些内存泄漏,找到造成问题的代码很难,特别是对于历史遗留的内存泄漏。

现在,MLeaksFinder 0.2 来了。如果说 0.1 版本旨在帮助开发者发现内存泄漏,那么 0.2 版本的新特性,正是旨在帮助开发者更好地解决内存泄漏。MLeaksFinder 0.2 包括以下几个新特性:

  • assert 改为 alert
  • 追踪对象的生命周期
  • 查找循环引用链

下面,我们来逐一看一下这几个特性。

阅读全文 »
12

18 日志
8 分类

开源项目

JSPatch
MLeaksFinder
GYHttpMock
GYBootingProtection
GYDataCenter
GYMonitor
© 2020
程序 - Hexo
主题 - NexT.Mist
微信读书