阅读视图
探索 Swift 中的 async/await: 构建更清晰的并发模型
iOS RunLoop 介绍
RunLoop
一般来说,一个线程一次只能执行一个任务,任务执行完成后线程就会退出。为了保持线程的忙碌状态并在没有任务时将线程置于休眠状态,我们需要一种机制,这就是运行循环(RunLoop)。
简单来说,运行循环是一个事件驱动的大循环,它确保线程在有任务需要处理时保持忙碌状态,并在没有任务时进入休眠状态。下面是一个简单的伪代码示例,展示了运行循环的基本结构:
int main(int argc, char * argv[]) {
//程序一直运行状态
while (AppIsRunning) {
//睡眠状态,等待唤醒事件
id whoWakesMe = SleepForWakingUp();
//得到唤醒事件
id event = GetEvent(whoWakesMe);
//开始处理事件
HandleEvent(event);
}
return 0;
}
Runloop Mode
运行循环模式是一种机制,用于管理运行循环中的输入源(input sources)、定时器(timers)以及运行循环观察者(run loop observers)。
通过这种机制,运行循环可以在不同的模式下运行,以控制不同类型的事件源和任务,确保只有与当前模式相关联的源被处理,从而增强了对事件源和观察者的精确控制能力。一个 RunLoop 包含若干个 Mode,每个 Mode 又包含若干个 Source/Timer/Observer。
系统提供了以下几种常见的 Mode :
- NSDefaultRunLoopMode: 默认 Mode,通常主线程是在这个 Mode 下运行的。
- UITrackingRunLoopMode:界面跟踪 Mode,用于 ScrollView 追踪触摸滑动,保证界面滑动时不受其他 Mode 影响。
- UIInitializationRunLoopMode: 在刚启动 App 时第进入的第一个 Mode,启动完成后就不再使用。
- NSRunLoopCommonModes:这是一个可配置的常用模式组合。将输入源与这个模式关联也会将其与组中的每个模式关联起来。Timer 计时会被 scrollView 的滑动影响的问题可以通过将 timer 添加到NSRunLoopCommonModes来解决。
事件源
CFRunLoopSource 是对 input sources 的抽象。CFRunLoopSource 分为 source0 和 source1 两种类型:
Source1 是内核自动发送的,包含了一个 mach_port 和一个回调,被用于通过内核和其他线程相互发送消息。
Source0 是App内部事件,由 App 自己管理的 UIEvent、CFSocket 都是 source0,只包含了一个回调,它并不能主动触发事件。使用时,需要先调用 CFRunLoopSourceSignal(source),将这个 Source 标记为待处理,然后手动调用 CFRunLoopWakeUp(runloop) 来唤醒 RunLoop,让其处理这个事件。
timer 基于时间的触发器,在未来的指定时间将事件同步传递到线程。通过 CFRunLoopTimerRef 来实现,它和 NSTimer 是 toll-free bridged 的。当其加入到 RunLoop 时,RunLoop 会注册对应的时间点,当时间点到达时,RunLoop 会被唤醒以执行那个回调。
// timer 会自动添加到当前线程的 NSDefaultRunLoopMode 中
Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { timer in
// 执行定时器触发的操作
self.timerTick(timer)
}
// 将 timer 手动添加到当前线程的 NSRunLoopCommonModes 中
let timer = Timer(timeInterval: 1.0, repeats: true) { timer in
// 执行定时器触发的操作
self.timerTick(timer)
}
RunLoop.current.add(timer, forMode: .common)
运行循环观察者
Runloop 会在特定时刻发送状态变化的通知。当 RunLoop 的状态发生变化时,观察者就能通过回调来接收这个变化。Runloop 可观察时间点有以下几个:
typedef CF_OPTIONS(CFOptionFlags, CFRunLoopActivity) {
kCFRunLoopEntry = (1UL << 0), // 即将进入 Loop
kCFRunLoopBeforeTimers = (1UL << 1), // 即将处理 Timer
kCFRunLoopBeforeSources = (1UL << 2), // 即将处理 Source
kCFRunLoopBeforeWaiting = (1UL << 5), // 即将进入休眠
kCFRunLoopAfterWaiting = (1UL << 6), // 刚从休眠中唤醒
kCFRunLoopExit = (1UL << 7), // 即将退出Loop
kCFRunLoopAllActivities = 0x0FFFFFFFU // 观察所有状态变化
};
Runloop 事件队列
每次运行 runloop, 线程的 runloop 会处理挂起的事件,并通知相关的观察者。其具体的流程如下:
源码地址:opensource.apple.com/source/CF/C…
参考
使用Xcode预览快速查看应用程序界面
软件架构之SOLID原则
iOS定时器的选择:CADisplayLink、NSTimer和GCD定时器
iOS应用中经常需要使用定时器来处理某些任务,例如执行动画、更新UI等。iOS提供了多种定时器类型,包括CADisplayLink、NSTimer和GCD定时器。不同的定时器类型适用于不同的场景和需求,因此在选择定时器类型时需要根据具体的情况进行选择。
CADisplayLink
CADisplayLink是一种定时器类型,它可以让你在每秒钟屏幕更新时执行一段代码。CADisplayLink定时器的精度非常高,因为它是和屏幕刷新频率同步的,所以可以确保动画的流畅度。另外,CADisplayLink定时器的调用方法是通过RunLoop进行的,所以它是线程安全的。
使用CADisplayLink定时器的步骤如下:
- 创建CADisplayLink对象。
- 设置定时器的目标和选择器。
- 将CADisplayLink添加到RunLoop中。
CADisplayLink *displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(update)];
[displayLink addToRunLoop:[NSRunLoop currentRunLoop] forMode:NSDefaultRunLoopMode];
NSTimer
NSTimer是iOS中另一种常用的定时器类型,它可以让你在一段时间后执行一段代码。NSTimer定时器的精度相对较低,因为它不是和屏幕刷新频率同步的,所以在一些对精度要求比较高的场景下可能不适用。另外NSTimer定时器的调用方法是通过RunLoop进行的,所以它也是线程安全的。
使用NSTimer定时器的步骤如下:
- 创建NSTimer对象。
- 设置定时器的目标和选择器。
- 将NSTimer添加到RunLoop中。
NSTimer *timer = [NSTimer scheduledTimerWithTimeInterval:1.0 target:self selector:@selector(update) userInfo:nil repeats:YES];
[[NSRunLoop currentRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];
需要注意的是,:NSTimer 被添加到 RunLoop 后会持有目标对象,容易导致循环引用问题,需要注意解除循环引用。
GCD定时器
GCD定时器是iOS中一种常见的定时器方式,使用Grand Central Dispatch (GCD)框架提供的功能实现。相比于传统的NSTimer和CADisplayLink,GCD定时器具有更高的精度和更好的性能,尤其是在多线程场景下表现更为优秀。
GCD定时器的实现原理是使用GCD的dispatch_source_t来创建一个定时器源,然后将该定时器源与需要执行的任务关联起来。通过GCD的API可以设置定时器的触发时间、重复次数等参数,并且可以很方便地在多线程环境下使用。
下面是一个简单的GCD定时器的示例代码:
// 创建一个GCD定时器
dispatch_source_t timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, dispatch_get_main_queue());
// 设置定时器的触发时间、间隔时间和重复次数
dispatch_time_t start = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC));
uint64_t interval = (uint64_t)(1.0 * NSEC_PER_SEC);
dispatch_source_set_timer(timer, start, interval, 0);
// 设置定时器的触发事件
dispatch_source_set_event_handler(timer, ^{
NSLog(@"GCD Timer fired");
});
// 启动定时器
dispatch_resume(timer);
需要注意的是,在使用GCD定时器时,我们需要确保在合适的时间停止定时器,并释放相关资源。停止 Dispatch Timer 有两种方法,一种是使用 dispatch_source_cancel,另外一种是使用 dispatch_suspend。
- 使用dispatch_source_cancel函数停止定时器,示例代码如下:
// 停止定时器
dispatch_source_cancel(timer);
// 释放资源
timer = nil;
- 使用dispatch_suspend函数停止定时器,dispatch_suspend 严格上只是把 Timer 暂时挂起,它和 dispatch_resume 是平衡调用的,两者分别会减少和增加 dispatch 对象的挂起计数,当这个计数大于 0 的时候,Timer 就会执行。
另外一个很重要的注意事项,dispatch_suspend 之后的 Timer,不能被释放!下面的代码会引起崩溃:
- (void)dealloc {
dispatch_suspend(timer);
timer = nil; // EXC_BAD_INSTRUCTION 崩溃
}
这是因为 GCD 的 dispatch source 在释放的时候会判断当前是否处于挂起状态。如果是挂起状态,则需要在调用 dispatch_resume() 恢复到活动状态后才能正常释放,否则会产生崩溃。
总结
在本文中,我们介绍了三种常见的定时器方法:CADisplayLink、NSTimer和GCD定时器。这些定时器方法都有其优点和适用场景。CADisplayLink主要用于渲染动画,NSTimer用于周期性执行任务,而GCD定时器则更加灵活,可以在不同线程中执行任务。
需要注意的是,在使用这些定时器方法时,我们要避免一些常见的问题。例如,在使用CADisplayLink时,要注意循环引用的问题;在使用NSTimer时,要注意循环引用和线程阻塞的问题;在使用GCD定时器时,要注意定时器的生命周期和线程安全的问题。
总的来说,我们应该根据实际的需求选择合适的定时器方法,并且合理地使用这些方法,避免出现一些常见的问题,从而保证程序的正常运行。
深入理解Objective-C中的@Synchronized关键字
iOS 中的数据持久化
在应用开发过程中,数据持久化是不可或缺的一部分。今天的文章会和大家介绍一下 iOS 中的数据持久化方案及相关特点,以便在开发过程中选择合适的数据持久化方案,避免出现不必要的错误。
沙盒机制
出于安全的原因,iOS 应用在安装时,为每个 App 分配了独立的目录,App 只能对自己的目录进行操作,这个目录就被称为沙盒。
与安卓系统不同,iOS 系统比较封闭,没有提供类似的内存卡扩展功能,也没有开放的文件管理,所以 iOS 系统的手机上是看不到文件目录的。
沙盒中主要包含4个目录: MyApp.app、Documents、Library、Tmp,目录结构如下:
MyApp.app
该目录用于存放应用本身的数据,包括资源文件和可执行文件。应用在被安装时,会将该目录签名,如果修改这个目录,签名会被改变,应用将无法启动。不会被 iTunes 和 iCloud 同步。
Documents
通常用来保存用户数据,用户数据通常包括希望向用户公开的任何文件(希望用户创建、导入、删除或编辑的任何内容)。该目录可以通过文件共享提供给用户。会被iTunes和iCloud同步。
NSString *docDir = [NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES) firstObject];
Library
此目录通常包含应用程序运行时使用的文件,这些文件对用户是不可见的。该目录除 Caches 子目录以外会被 iTunes 和 iCloud 同步。
Library/Caches
通常用于存储运行时产生的临时文件及缓存文件,在空间不足时可能会被系统自动清除,因此,应用程序应该具备重新创建和下载这些文件的能力。
NSString *cachesDir = [NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES) firstObject];
Library/Preferences
通常用于存储应用的偏好设置数据,不要试图直接访问该目录。修改此目录下的 plist 文件,可能会导致修改丢失、延迟反映更改或应用程序崩溃等意外情况。
Temp
用来保存不重要的临时文件,在应用程序没有运行时,系统会自动清除这些文件,因此,在应用程序终止后,不能依赖这些文件的持久性。不会被 iTunes 和 iCloud 同步。
NSString *tmpDir = NSTemporaryDirectory();
序列化与反序列化
要将对象存储到磁盘,需要将其转化成二进制数据,这一步叫做序列化。相反,将二进制数据转换成对象,则称为反序列化。
iOS 中的对象要实现序列化和反序列化,需要实现 NSCoding 协议:
-(void) encodeWithCoder:(NSCoder *)aCoder;
-(instancetype) initWithCoder:(NSCoder *)aDecoder;
举个例子:
@interface User : NSObject<NSCoding>
-(instancetype) initWithCoder:(NSCoder *)aDecoder {
_userName = [aDecoder decodeObjectForKey:@"usrName"];
_passWord = [aDecoder decodeObjectForKey:@"passWord"];
}
-(void) encodeWithCoder:(NSCoder *)aCoder {
[aCoder encodeObject:_userName forKey:@"userName"];
[aCoder encodeObject:_passWord forKey:@"passWord"];
}
@end
- 存储:
BOOL flag = [NSKeyedArchiver archiveRootObject:user toFile:path];
- 读取:
user = [NSKeyedUnarchiver unarchiveObjectWithFile: path];
此外,也可以使用一些优秀的三方库(如:YYModel)来实现对象的序列化和反序列化。
数据持久化方案介绍
NSUserDefaults
NSUserDefaults 是轻量级的数据持久化方案,主要用于存储应用程序的配置信息等一些比较小的数据。其特点如下:
- 是一个单例,且是线程安全的。
- 存储在 Library/Preferences 目录下。
- 以 plist 的形式进行存储。
- 当存储的数据是可变类型时,读取时会变为不可变。
synchronize
NSUserDefaults 会定时把缓存中的数据写入磁盘,而不是立即写入,为了防止在写完 NSUserDefaults 后程序退出导致的数据丢失,可以在写入数据后使用 synchronize 来强制立即将数据写入磁盘:
[[NSUserDefaults standardUserDefaults] synchronize];
但也要注意,不能频繁的使用 synchronize。
支持的数据类型
支持的数据类型有 NSData, NSString, NSNumber, NSDate, NSArray, NSDictionary 等系统定义的数据类型,如果要存放其他数据类型或者自定义的对象,则必须将其转换成 NSData 存储。
SQLite3
用来存储大规模的数据,是一款轻型的关系型数据库。它具有操作简单、小巧、快速,可靠的特点。
创建和打开数据库
NSString *docPath = [NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES) lastObject];
NSString *fileName = [docPath stringByAppendingPathComponent:@"test.sqlite"];
const char *cFileName = fileName.UTF8String;
int result = sqlite3_open(cFileName, &_db); // 打开数据库文件, 如果数据库文件不存在,会自动创建数据库文件
if (result != SQLITE_OK) {
NSLog(@"打开数据库失败");
return;
}
NSLog(@"打开数据库成功");
建表
const char *sql = "CREATE TABLE IF NOT EXISTS test_table (id integer PRIMARY KEY AUTOINCREMENT, test_key char)";
char *errMsg = NULL;
result = sqlite3_exec(_db, sql, NULL, NULL, &errMsg);
if (result == SQLITE_OK) {
NSLog(@"创建表成功");
} else {
NSLog(@"创建表失败");
}
执行 sql 语句
sqlite3_stmt *stmt;
const char *insertSQL = "insert into test_table(test_key) values('test')";
int result = sqlite3_prepare_v2(_db, insertSQL, -1, &stmt, nil)
if (result == SQLITE_OK) {
NSLog(@"插入数据成功");
} else {
NSLog(@"插入数据失败");
}
常用的三方库
由于 sqlite3 的原生语言是 C 语言,与 OC 的使用风格不一样,对于 iOS 开发者来说,不是很友好,容易出错,这里列出一些封装好的三方库,大家可以根据实际情况选择使用。
- FMDB
- WCDB
- Realm
CoreData
CoreData 是苹果提供的一种应用数据管理框架,可以通过图形界面的方式快速定义 App 的数据模型,并且提供了对象模型和关系数据映射的能力,将模型对象转化成关系数据保存到 SQLite 数据库中,也可以将保存到 SQLite 数据库中的关系数据转换成对象模型。
加载数据模型
NSURL *modelPath = [[NSBundle mainBundle] URLForResource:@"Student" withExtension:@"momd"];
NSManagedObjectModel *model = [[NSManagedObjectModel alloc] initWithContentsOfURL:modelPath];
创建数据库
NSPersistentStoreCoordinator *coordinator = [[NSPersistentStoreCoordinator alloc] initWithManagedObjectModel:model];
NSString *dataPath = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES).lastObject;
dataPath = [dataPath stringByAppendingPathComponent:@"Student.sqlite"];
[coordinator addPersistentStoreWithType:NSSQLiteStoreType configuration:nil URL:[NSURL fileURLWithPath:dataPath] options:nil error:nil];
数据库关联缓存
NSManagedObjectContext *context = [[NSManagedObjectContext alloc] initWithConcurrencyType:NSMainQueueConcurrencyType];
context.persistentStoreCoordinator = coordinator;
插入数据
Student * student = [NSEntityDescription insertNewObjectForEntityForName:@"Student" inManagedObjectContext:context];
student.name = @"albert";
student.age = 22;
NSError *error = nil;
[context save:&error];
查询数据
NSFetchRequest *request = [NSFetchRequest fetchRequestWithEntityName:@"Student"];
NSPredicate *predicate = [NSPredicate predicateWithFormate:@"age=22"]; // 查询条件
request.predicate = predicate;
NSError *error = nil;
NSArray<Student *> *students = [context executeFetchRequest:request error:&error];
Keychain
提供了一种用于安全存储敏感信息方式。其特点如下:
- 保存到 keychain 中的信息不会因为卸载或重装 App 而丢失。
- keychain 是用 SQLite 进行存储的,苹果会对其进行加密。
- 适合存储一些比较小的数据。
- 可以通过 Group 的方式,在多个 App间共享。
API
- SecItemAdd:添加一个item
- SecItemUpdate:更新已存在的item
- SecItemCopyMatching:搜索一个已存在的item
- SecItemDelete:删除一个keychain item
三方库
系统提供的 API 不是 OC 风格,使用起来不是很友好,这里推荐两个三方库供大家选择:
- KeychainWrapper
- SAMKeychain
关注公众号 iOS学习社区 get更多技术好文
# iOS GCD 之 dispatch_group_enter 和 dispatch_group_leave
在实际开发中,经常需要在几个任务全部执行完成之后,在执行后续操作,在 iOS 中,我们可以通过 NSOperation 等达到这一目的。在本篇文章中,我们会介绍如何通过 dispatch_group_enter 和 dispatch_group_leave 来实现这一功能,以及使用过程中遇到的坑。
如何使用
通过一个例子来看下如何使用 dispatch_group_enter 和 dispatch_group_leave:
{
// 首先 需要创建一个线程组
dispatch_group_t group = dispatch_group_create();
// 任务1
dispatch_group_enter(group);
NSURLSessionDataTask *task1 = [[NSURLSession sharedSession] dataTaskWithURL:[NSURL URLWithString:@""] completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
NSLog(@"任务1完成");
dispatch_group_leave(group);
}];
[task1 resume];
// 任务2
dispatch_group_enter(group);
NSURLSessionDataTask *task2 = [[NSURLSession sharedSession] dataTaskWithURL:[NSURL URLWithString:@""] completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
NSLog(@"任务2完成");
dispatch_group_leave(group);
}];
[task2 resume];
// 全部完成
dispatch_group_notify(group, dispatch_get_main_queue(), ^(){
NSLog(@"全部完成");
});
}
任务1和任务2执行完成之后,才会执行全部完成中的任务。
**注意:**在使用时,dispatch_group_enter 和 dispatch_group_leave 需要成对出现,如果 dispatch_group_leave 的调用次数多于 dispatch_group_enter 的调用次数,程序会 crash。相反,虽然不会发生 crash , 但可能不会达到预期效果。
crash 场景分析
使用场景是,需要异步获取多个图片封面,所有都获取完成后,在执行指定任务,代码示例如下:
- (void)fetchCovers {
dispatch_queue_t queue = dispatch_queue_create("com.demo.xxx", DISPATCH_QUEUE_CONCURRENT);
dispatch_group_t group = dispatch_group_create();
for (int i = 0; i < 40; ++i) {
dispatch_group_enter(group);
dispatch_async(queue, ^{
[self fetchCoverByPHAsset:asset targetSize:CGSizeMake(200, 200) resultHandler:^(UIImage * _Nonnull, NSDictionary * _Nonnull, BOOL) {
dispatch_group_leave(group);
}];
});
}
dispatch_group_notify(group, dispatch_get_main_queue(), ^(){
// 全部完成后 执行指定任务
});
}
- (void)fetchCover:(PHAsset *)asset targetSize:(CGSize)targeSize resultHandler:(void (^)(UIImage * _Nonnull, NSDictionary * _Nonnull, BOOL))resultHandler {
PHImageRequestOptions *options = [[PHImageRequestOptions alloc] init];
if (@available(iOS 14.0, *)) {
options.version = PHImageRequestOptionsVersionCurrent;
}
options.networkAccessAllowed = YES;
[[PHImageManager defaultManager] requestImageForAsset:asset
targetSize:targeSize
contentMode:PHImageContentModeAspectFill
options:options
resultHandler:^(UIImage * _Nullable result, NSDictionary * _Nullable info) {
if (resultHandler) {
resultHandler(result, info, [[info objectForKey:PHImageResultIsDegradedKey] boolValue]);
}
}];
}
这里有个小 tips : 在使用 requestImageForAsset 获取图片时,如果 options 的 deliveryMode 属性使用默认值,在异步获取图片时,其回调可能会走2次。解决方案是将其显示设置为 PHImageRequestOptionsDeliveryModeHighQualityFormat 或 PHImageRequestOptionsDeliveryModeFastFormat。
options.deliveryMode = PHImageRequestOptionsDeliveryModeFastFormat; // 或 PHImageRequestOptionsDeliveryModeHighQualityFormat
因为获取图片封面的回调可能会走 2 次,从而导致 dispatch_group_leave 调用次数多于 dispatch_group_enter 的调用次数,因此可能会发生 crash。
源码实现
- dispatch_group_enter
void
dispatch_group_enter(dispatch_group_t dg)
{
uint32_t old_bits = os_atomic_sub_orig2o(dg, dg_bits,
DISPATCH_GROUP_VALUE_INTERVAL, acquire);
uint32_t old_value = old_bits & DISPATCH_GROUP_VALUE_MASK;
if (unlikely(old_value == 0)) {
_dispatch_retain(dg); // <rdar://problem/22318411>
}
if (unlikely(old_value == DISPATCH_GROUP_VALUE_MAX)) {
DISPATCH_CLIENT_CRASH(old_bits,
"Too many nested calls to dispatch_group_enter()");
}
}
- dispatch_group_leave
void
dispatch_group_leave(dispatch_group_t dg)
{
uint64_t new_state, old_state = os_atomic_add_orig2o(dg, dg_state,
DISPATCH_GROUP_VALUE_INTERVAL, release);
uint32_t old_value = (uint32_t)(old_state & DISPATCH_GROUP_VALUE_MASK);
if (unlikely(old_value == DISPATCH_GROUP_VALUE_1)) {
old_state += DISPATCH_GROUP_VALUE_INTERVAL;
do {
new_state = old_state;
if ((old_state & DISPATCH_GROUP_VALUE_MASK) == 0) {
new_state &= ~DISPATCH_GROUP_HAS_WAITERS;
new_state &= ~DISPATCH_GROUP_HAS_NOTIFS;
} else {
new_state &= ~DISPATCH_GROUP_HAS_NOTIFS;
}
if (old_state == new_state) break;
} while (unlikely(!os_atomic_cmpxchgv2o(dg, dg_state,
old_state, new_state, &old_state, relaxed)));
return _dispatch_group_wake(dg, old_state, true);
}
if (unlikely(old_value == 0)) {
DISPATCH_CLIENT_CRASH((uintptr_t)old_value,
"Unbalanced call to dispatch_group_leave()");
}
}
Swift 模式匹配
iOS UITableView estimatedRowHeight 小记
estimatedRowHeight 是 iOS7.0 以后引入的属性,用来预估列表视图的高度。下面看一下官网的解释:
大概的意思是:
为行高提供一个非负的预估值,可以提高列表视图的加载性能。如果列表包含高度可变的行,则在加载表时计算这些行的所有高度可能会非常昂贵。估算允许您将几何体计算的一些成本从加载时间推迟到滚动时间。
其默认值是 automaticDimension,这意味着表视图会默认选择一个预估高度供你使用。将该值设置为0将禁用估计高度,这将导致表视图请求每个单元格的实际高度。如果表使用自调整大小的单元格,则此属性的值不能为0。
使用预估高度时,表视图会自动管理从滚动视图继承的 contentOffset 和 contentSize 属性。不要试图直接读取或修改这些属性。
注意:estimatedRowHeight 在 iOS11 之前默认值为0,在 iOS11 之后,默认值为automaticDimension。
例子
下面我们通过一个例子来从下面两个方面来了解这个属性:
- tableView:heightForRowAtIndexPath: 和 tableView:cellForRowAtIndexPath 执行次数
- contentSize 的变化情况。
- (void)viewDidLoad {
[super viewDidLoad];
for (NSInteger i = 0; i < 100; ++i) {
NSString *text = [NSString stringWithFormat:@"%ld", i];
[self.dataSource addObject:text];
}
[self.listView reloadData];
[self.listView addObserver:self forKeyPath:@"contentSize" options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld context:NULL];
}
- (NSMutableArray *)dataSource {
if (!_dataSource) {
_dataSource = [NSMutableArray arrayWithCapacity:10];
}
return _dataSource;
}
- (UITableView *)listView {
if (!_listView) {
_listView = [[UITableView alloc] initWithFrame:self.view.bounds style:UITableViewStylePlain];
_listView.delegate = self;
_listView.dataSource = self;
[self.view addSubview:_listView];
}
return _listView;
}
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
return self.dataSource.count;
}
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
NSLog(@"height at row:%ld", indexPath.row);
return 200;
}
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"testCell"];
if (!cell) {
cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:@"testCell"];
}
cell.textLabel.text = self.dataSource[indexPath.row];
NSLog(@"cell at row:%ld", indexPath.row);
return cell;
}
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context{
if ([keyPath isEqualToString:@"contentSize"]) {
NSLog(@"contentSize:%@", NSStringFromCGSize(self.listView.contentSize));
}
}
禁用 estimatedRowHeight 属性:
打印结果如下:
2021-02-17 15:44:21.220452+0800 CategoryDemo[39013:1055808] height at row:0
...
2021-02-17 15:44:21.247421+0800 CategoryDemo[39013:1055808] height at row:49
2021-02-17 16:10:23.095358+0800 CategoryDemo[40146:1088361] contentSize:{414, 10000}
2021-02-17 15:44:21.261236+0800 CategoryDemo[39013:1055808] cell at row:0
2021-02-17 15:44:21.262052+0800 CategoryDemo[39013:1055808] height at row:0
2021-02-17 15:44:21.263151+0800 CategoryDemo[39013:1055808] cell at row:1
2021-02-17 15:44:21.263665+0800 CategoryDemo[39013:1055808] height at row:1
2021-02-17 15:44:21.264298+0800 CategoryDemo[39013:1055808] cell at row:2
2021-02-17 15:44:21.264718+0800 CategoryDemo[39013:1055808] height at row:2
2021-02-17 15:44:21.265399+0800 CategoryDemo[39013:1055808] cell at row:3
2021-02-17 15:44:21.265783+0800 CategoryDemo[39013:1055808] height at row:3
2021-02-17 15:44:21.266447+0800 CategoryDemo[39013:1055808] cell at row:4
2021-02-17 15:44:21.266824+0800 CategoryDemo[39013:1055808] height at row:4
通过打印结果可以看出:
- tableView:heightForRowAtIndexPath:方法会先全部执行一遍。
- 只加载可见区域内的 cell。
- 加载 cell 时又调用了一遍 tableView:heightForRowAtIndexPath: 方法。
- 一次生成 contentSize,值不会变化。
不禁用 estimatedRowHeight 属性:
打印结果如下:
2021-02-17 16:17:57.565084+0800 CategoryDemo[43013:1104211] contentSize:{414, 2200}
2021-02-17 16:17:57.581135+0800 CategoryDemo[43013:1104211] cell at row:0
2021-02-17 16:17:57.582222+0800 CategoryDemo[43013:1104211] height at row:0
...
2021-02-17 16:17:57.684118+0800 CategoryDemo[43013:1104211] cell at row:19
2021-02-17 16:17:57.684557+0800 CategoryDemo[43013:1104211] height at row:19
2021-02-17 16:17:57.685262+0800 CategoryDemo[43013:1104211] contentSize:{414, 5320}
- 加载 20 个 cell(如果20个cell的高度小于tableview的可见区,则加载可见区内的cell),可能会影响展现埋点。
- 只在加载 cell 时调用一遍 tableView:heightForRowAtIndexPath: 方法。
- contentSize 的值会变化。
总结
-
在禁用预估高度时,系统会先把所有 cell 的实际高度先计算出来,也就是先执行tableView:heightForRowAtIndexPath:代理方法,接着用获取的 cell 实际高度的总和计算tableView 的 contentSize,然后才显示tableViewCell的内容。在这个过程中,如果实际高度计算比较复杂的话,就会消耗更多的性能。
-
在使用预估高度时,系统会先使用预估高度来计算 tableView 的 contentSize, 因此 contentSize 的高度会动态变化,如果差值为0,tableView 的 contentSize 高度不再变化。由于使用预估高度代替了实际高度的计算,减少了实际高度计算时的性能消耗,但是这种实际高度和预估高度差值的动态变化在滑动过快时可能会产生跳跃现象,所以预估高度和真实高度越接近越好。
一次 category 的误用引发的 crash
在最近的一次开发中,不小心在自定义的 UIViewController 的 category 中重写了 dealloc 方法,导致项目中莫名出现了许多野指针的 crash,虽然重写 dealloc 方法会引发一些不确定的行为,但是为什么会引发 crash 呢?带着疑问又重新温习了下 category 的源码。
Category 的底层实现
可以在 objc4 源码的 objc-runtime-new.m 文件中看到它的实现,如下:
typedef struct category_t *Category;
struct category_t {
const char *name; // Category 的名字
classref_t cls; // 要扩展的类
struct method_list_t *instanceMethods; // category的实例方法列表
struct method_list_t *classMethods; // category类方法列表
struct protocol_list_t *protocols; // category的协议列表
struct property_list_t *instanceProperties; // category的属性列表
method_list_t *methodsForMeta(bool isMeta) {
if (isMeta) return classMethods;
else return instanceMethods;
}
property_list_t *propertiesForMeta(bool isMeta) {
if (isMeta) return nil; // classProperties;
else return instanceProperties;
}
};
通过源码可以看出,category 其实就是一个 category_t 类型的结构体,它维护要扩展类和分类的相关信息。
扩展:从源码中可以看出,分类的结构体中并没有成员变量的存储方式,这就解释了为什么在分类中无法添加
成员变量了。
此外还需要注意的是,虽然分类中会提供了属性列表的的存储方式,但它并不会帮我们自动生成成员变量,它
只会生成setter getter方法的声明,具体还需要我们自己去实现。
category 是如何加载的
OC 的 runtime 是通过 dyld 动态加载的,而 _objc_init()
方法是 runtime 被加载后第一个执行的方法。我们从_objc_init()
开始来追溯 category 的加载过程。
首先看下 _objc_init()
的实现,如下:
void _objc_init(void)
{
static bool initialized = false;
if (initialized) return;
initialized = true;
// fixme defer initialization until an objc-using image is found?
environ_init();
tls_init();
static_init();
lock_init();
exception_init();
// Register for unmap first, in case some +load unmaps something
_dyld_register_func_for_remove_image(&unmap_image);
dyld_register_image_state_change_handler(dyld_image_state_bound,
1/*batch*/, &map_2_images);
dyld_register_image_state_change_handler(dyld_image_state_dependents_initialized, 0/*not batch*/, &load_images);
}
我们看下 dyld_register_image_state_change_handler(dyld_image_state_bound, 1/*batch*/, &map_2_images)
,这里注册了一个回调,当 dyld_image 状态为 dyld_image_state_bound 时,触发map_2_images
用来将 image Map到内存 ,其实现如下:
const char *
map_2_images(enum dyld_image_states state, uint32_t infoCount,
const struct dyld_image_info infoList[])
{
rwlock_writer_t lock(runtimeLock);
return map_images_nolock(state, infoCount, infoList);
}
这里会调用 map_images_nolock 方法,map_images_nolock 的源码很多,我们就不在这里列出来了,感兴趣的同学可以自己去查阅源码。在 map_images_nolock 的实现中,我们会发现一个重要函数_read_images
,它用来初始化 Map 后的 image。
继续查看 _read_images
的源码,在 Discover categories 的代码段中,会调用一个关键函数 remethodizeClass,其实现如下:
static void remethodizeClass(Class cls)
{
category_list *cats;
bool isMeta;
runtimeLock.assertWriting();
isMeta = cls->isMetaClass();
// Re-methodizing: check for more categories
if ((cats = unattachedCategoriesForClass(cls, false/*not realizing*/))) {
if (PrintConnecting) {
_objc_inform("CLASS: attaching categories to class '%s' %s",
cls->nameForLogging(), isMeta ? "(meta)" : "");
}
attachCategories(cls, cats, true /*flush caches*/);
free(cats);
}
}
这里会调用 attachCategories 函数将类别中的方法、属性、协议附加到类上,源码如下:
static void
attachCategories(Class cls, category_list *cats, bool flush_caches)
{
if (!cats) return;
if (PrintReplacedMethods) printReplacements(cls, cats);
bool isMeta = cls->isMetaClass();
// fixme rearrange to remove these intermediate allocations
method_list_t **mlists = (method_list_t **)
malloc(cats->count * sizeof(*mlists));
property_list_t **proplists = (property_list_t **)
malloc(cats->count * sizeof(*proplists));
protocol_list_t **protolists = (protocol_list_t **)
malloc(cats->count * sizeof(*protolists));
// Count backwards through cats to get newest categories first
int mcount = 0;
int propcount = 0;
int protocount = 0;
int i = cats->count;
bool fromBundle = NO;
while (i--) {
auto& entry = cats->list[i];
method_list_t *mlist = entry.cat->methodsForMeta(isMeta);
if (mlist) {
mlists[mcount++] = mlist;
fromBundle |= entry.hi->isBundle();
}
property_list_t *proplist = entry.cat->propertiesForMeta(isMeta);
if (proplist) {
proplists[propcount++] = proplist;
}
protocol_list_t *protolist = entry.cat->protocols;
if (protolist) {
protolists[protocount++] = protolist;
}
}
auto rw = cls->data();
prepareMethodLists(cls, mlists, mcount, NO, fromBundle);
rw->methods.attachLists(mlists, mcount);
free(mlists);
if (flush_caches && mcount > 0) flushCaches(cls);
rw->properties.attachLists(proplists, propcount);
free(proplists);
rw->protocols.attachLists(protolists, protocount);
free(protolists);
}
attachCategories 函数中,首先会进行一些内存分配的工作,然后获取分类的方法、属性和协议,并放到指定的数组中,最后调用 attachLists 方法将分类和原类中的方法、属性、和协议进行了合并。看下 attachLists 函数的实现:
void attachLists(List* const * addedLists, uint32_t addedCount) {
if (addedCount == 0) return;
if (hasArray()) {
// many lists -> many lists
uint32_t oldCount = array()->count;
uint32_t newCount = oldCount + addedCount;
setArray((array_t *)realloc(array(), array_t::byteSize(newCount)));
array()->count = newCount;
memmove(array()->lists + addedCount, array()->lists,
oldCount * sizeof(array()->lists[0]));
memcpy(array()->lists, addedLists,
addedCount * sizeof(array()->lists[0]));
}
else if (!list && addedCount == 1) {
// 0 lists -> 1 list
list = addedLists[0];
}
else {
// 1 list -> many lists
List* oldList = list;
uint32_t oldCount = oldList ? 1 : 0;
uint32_t newCount = oldCount + addedCount;
setArray((array_t *)malloc(array_t::byteSize(newCount)));
array()->count = newCount;
if (oldList) array()->lists[addedCount] = oldList;
memcpy(array()->lists, addedLists,
addedCount * sizeof(array()->lists[0]));
}
}
这个函数中,通过 memmove 和 memcpy 的操作,将分类的方法、属性、协议列表放入了类对象中原本存储的方法、属性、协议列表的前面。
为什么会出现 crash
我们通过一个例子来复现下文章开头提到的 crash , 代码如下:
// 在 UIViewController+Test.m 的类别中重写 dealloc 方法
- (void)dealloc {
}
// TestAViewController.m
@interface TestAViewController ()
@property (nonatomic, strong) TestBViewController *bViewController;
@end
@implementation TestAViewController
- (void)viewDidLoad {
[super viewDidLoad];
// Do any additional setup after loading the view.
self.view.backgroundColor = UIColor.whiteColor;
_bViewController = [TestBViewController new];
__weak typeof (self) weakSelf = self;
_bViewController.willDismiss = ^{
__strong typeof(weakSelf) strongSelf = weakSelf;
[strongSelf.navigationController popViewControllerAnimated:YES];
};
[self setDefinesPresentationContext:YES];
[self.bViewController setModalPresentationStyle:UIModalPresentationCurrentContext];
}
- (void)viewDidAppear:(BOOL)animated {
[super viewDidAppear:animated];
if (!self.isFirst) {
[self presentViewController:self.bViewController animated:YES completion:nil];
}
self.isFirst = YES;
};
// TestBViewController.m 中有个关闭按钮 点击执行下面的方法
- (void)onDismiss {
if (self.willDismiss) {
self.willDismiss();
}
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 1 * NSEC_PER_SEC), dispatch_get_main_queue(), ^{
if (self.presentingViewController) {
[self dismissViewControllerAnimated:YES completion:^{
}];
}
});
}
运行程序,点击 TestBViewController 中的关闭按钮,出现如下 crash:
*** -[TestAViewController retain]: message sent to deallocated instance 0x7fba53d161d0
从这条日志可以看出,是因为访问了已经释放的对象地址,导致的 crash.
分析:因为在类别中重写了 dealloc 方法,会导致 UIViewController 本身的 dealloc 方法不会执行,这样就不会释放 dealloc 中的成员变量,指向这些成员变量的指针会变成野指针,如果通过野指针不小心访问了已经释放的 UIViewController 对象的地址,就会出现上面的 crash.
总结
虽然 category 为我们提供了许多便利,但是我们在使用时也有多加小心,以免掉入陷阱,下面用苹果官网的一段话作为结束:
Avoid Category Method Name Clashes
Because the methods declared in a category are added to an existing class,
you need to be very careful about method names.
If the name of a method declared in a category is the same as a method in the
original class, or a method in another category on the same class (or even a
superclass), the behavior is undefined as to which method implementation is used
at runtime. This is less likely to be an issue if you’re using categories with
your own classes, but can cause problems when using categories to add methods to
standard Cocoa or Cocoa Touch classes.
----扫一扫关注公众号,get更多技术好文-----
iOS性能优化之图片最佳实践
UIImage 是用来处理图像数据的高级类,UIImageView 是 UIKit 提供的用于显示 UIImage 的类。若采用 MVC 模型进行类比,UIImage 可以看作模型对象(Model),UIImageView 是一个视图(View)。它们都肩负着各自的职责:
UIImage 负责加载图片内容, UIImageView 负责显示和渲染它。
这看似是一个简单的单向过程,但实际情况却复杂的多,因为渲染是一个连续的过程,而不是一次性事件。这里还有一个非常关键的隐藏阶段,对衡量 app 性能至关重要,这个阶段被称为解码。
图片解码
在讨论解码之前,先了解下缓冲区的概念。
缓冲区:是一块连续的内存区域,用来表示一系列元素组成的内存,这些元素具有相同的尺寸,并通常具有相同的内部结构。
图像缓冲区:它是一种特定缓冲区,它保存了某些图像在内存中的表示。此缓冲区的每个元素,描述了图像中每个像素的颜色和透明度。因此这个缓冲区在内存中的大小与它包含的图像大小成正比。
帧缓冲区:它保存了 app 中实际渲染后的输出。因此,当 app 更新其视图层次结构时,UIKit 将重新渲染 app 的窗口及其所有视图到帧缓冲区中。帧缓冲区中提供了每个像素的颜色信息,显示硬件降读取这些信息用来点亮显示器上对应的像素。
如果 app 中没有任何改变,则显示硬件会从帧缓冲区中取出上次看到的相同数据。但是如果改变了视图内容,UIKit会重新渲染内容,并将其放入帧缓冲区,下一次显示硬件从帧缓冲区读取内容时,就会获取到新的内容。
数据缓冲区:包含图像文件的数据缓冲区,通常以某些元数据开头,这些元数据描述了存储在数据缓冲区中的图像大小和图像数据本身。
下面看下图像渲染到帧缓冲区的详细过程:
这块区域将由图像视图进行渲染填充。我们已经为图像视图分配一个 UIImage,它有一个表示图像文件内容的数据缓冲区。我们需要用每个像素的数据来填充帧缓冲区,为了做到这一点,UIImage 将分配一个图像缓冲区,其大小等于包含在数据缓冲区中的图像大小,并执行称为解码的操作,这就是将 JPEG 或 PNG 或其它编码的图像数据转换为每个像素的图像信息。然后取决于我们图像视图的内容模式,当 UIKit 要求图像视图进行渲染时,它会将数据复制到帧缓冲区的过程中对来自图像缓冲区的数据进行复制和缩放。
解码阶段是 CPU 密集型的,特别是对于大型图像。因此,不是每次 UIKit 要求图像视图渲染时都执行一次这个过程。UIImage 绑定在图像缓冲区上,所以它只执行一次这个过程。因此,在你的 app 中,对于每个被解码的图像,都可能会持续存在大量的内存分配,这种内存分配与输入的图像大小成正比,而与帧缓冲区中实际渲染的图像视图大小没有必然联系,这会对内存产生相当不利的后果。
减少 CPU 的使用率
我们可以使用一种称为向下采样的技术来实现这一目标。
我们可以通过这种下采样技术来节省一些内存。本质上,我们要做的就是捕捉该缩小操作,并将其放入缩略图的对象中,最终达到降低内存的目的,因为我们将有一个较小的解码图像缓冲区。
这样,我们设置了一个图像源,创建了一个缩略图,然后将解码缓冲区捕获到 UIImage 中,并将该 UIImage 分配给我们的图像视图。接下来我们就可以丢弃包含图片数据的数据缓冲区,最终结果就是我们的 app 中将具有一个更小的长期内存占用足迹。
下面看下如何使用代码来实现这一过程:
- 首先,创建一个 CGImageSource 对象
let imageSourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary
let imageSource = CGImageSourceCreateWithURL(imageURL as CFURL, imageSourceOptions)!
KCGImageSourceShouldCache 参数为 false,用来告诉 Core Graphic 框架我们只是在创建一个对象,来表示存储在该 URL 的文件中的信息,不要立即解码这个图像,只需要创建一个表示它的对象,我们需要来自此 URL 的文件信息。
- 然后在水平和垂直轴上进行计算,该计算基于期望的图片大小以及我们要渲染的像素和点大小:
let maxDimensionInPixels = max(pointSize.width, pointSize.height) * scale
let downsampleOptions = [kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceShouldCacheImmediately: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceThumbnailMaxPixelSize: maxDimensionInPixels] as CFDictionary
这里也创建了一个缩略图选项的字典,最重要的是 CacheImmediately 这个选项,通过这个选项,告诉 Core Graphics,当我们要求你创建缩略图时,这就是你应该为我创建解码缓冲区的确切时刻。因此,我们可以确切的控制何时调用 CPU 来进行解码。
- 最后,我们创建缩略图,即拿到返回的 CGImage 。
let downsampledImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, downsampleOptions)
其完整代码如下:
func downsample(imageAt imageURL: URL, to pointSize: CGSize, scale: CGFloat) -> UIImage {
let imageSourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary
let imageSource = CGImageSourceCreateWithURL(imageURL as CFURL, imageSourceOptions)!
let maxDimensionInPixels = max(pointSize.width, pointSize.height) * scale
let downsampleOptions = [kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceShouldCacheImmediately: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceThumbnailMaxPixelSize: maxDimensionInPixels] as CFDictionary
let downsampledImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, downsampleOptions)
return UIImage(cgImage: downsampledImage)
}
在 UICollectionView 中的使用
我们可能会在创建单元格时,直接使用下采样技术来生成的图片,代码如下:
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath) as! MyCollectionViewCell
cell.layoutIfNeeded()
let imageViewSize = cell.imageView.bounds.size
let scale = collectionView.traitCollection.displayScale
cell.imageView.image = downsample(imageAt: "", to: imageViewSize, scale: scale)
}
这样确实会减少内存的使用量,但这并不能解决我们的另一个问题。这些问题在可滚动的视图中是非常常见的。
当我们滚动页面时,CPU 相对比较空闲或它所做的工作可以在显示硬件需要帧缓冲的下一个副本之前完成,所以,当帧缓冲被更新时,我们能看到流畅的效果,并且显示硬件能及时获得新帧。
但是,如果我们将显示另一行图像,将单元格交回 UICollectionView 之前,我们要求 core Graphics 解码这些图像,这将会花费很长的 CPU 时间,以至于我们不得不重新渲染帧缓冲区,但显示器硬件按固定的时间间隔运行,因此,从用户的角度来看,app 好像卡住了一样。
这不仅会造成信息粘连,还会有明显的响应性后果,也对电池寿命有不利的影响。
我们可以使用两种技术来平滑我们的 CPU 使用率:
第一个是预取,它的基本思想是:预取允许 UICollectionView 告知我们的数据源,它当前不需要一个单元格,但它在不久的将来需要,因此,如果你有任何工作要做,也许现在可以提前开始。这允许我们随时间的推移,分摊 CPU 的使用率,因此,我们减少了CPU 使用的峰值。
另一种技术是在后台执行工作,既然我们已经随时间分散了工作量,我们也可以将这些技术分散到可用的 CPU 上。
这样做的效果是使你的 app 具有更强的响应性,并且该设备具有更长的电池寿命。
具体代码如下:
func collectionView(_ collectionView: UICollectionView,
prefetchItemsAt indexPaths: [IndexPath]) {
// Asynchronously decode and downsample every image we are about to show
for indexPath in indexPaths {
DispatchQueue.global(qos: .userInitiated).async {
let downsampledImage = downsample(images[indexPath.row])
DispatchQueue.main.async {
self.update(at: indexPath, with: downsampledImage)
}
}
}
}
我们在全局兵法队列中来使用下采样技术,但这里有个潜在的缺陷,就是有可能会引起线程爆炸。当我们要求系统去做比 CPU 能够做的工作更多的工作时,就会发生这种情况。
为类避免线程爆炸,我们现在不是简单的将工作分配到全局异步队列中,而是创建一个串行队列,并且在预取方法的实现中,异步的将任务分配到该队列中,实现如下:
let serialQueue = DispatchQueue(label: "Decode queue")
func collectionView(_ collectionView: UICollectionView,
prefetchItemsAt indexPaths: [IndexPath]) {
// Asynchronously decode and downsample every image we are about to show
for indexPath in indexPaths {
serialQueue.async {
let downsampledImage = downsample(images[indexPath.row])
DispatchQueue.main.async { self.update(at: indexPath, with: downsampledImage)
}
}
}
扫一扫关注公众号,get更多技术好文