性能监控:卡顿监控

卡顿监控

Instrument

一、卡顿的原因

卡顿的原因:掉帧。

通常设备以60Hz的频率刷新屏幕,即每16.67毫秒刷新一次。如果在16.67ms内帧缓冲区没有准备好下一帧数据就会使画面停留在上一帧,造成丢帧,没法达到60fps,从而形成卡顿的现象。

掉帧的原因:卡顿造成的原因是CPU或GPU耗时导致的掉帧引起的:

  • 主线程在进行大量I/O操作:直接主线程写入大量数据
  • 主线程进行大量计算:主线程进行大量复杂的计算
  • 大量UI绘制:界面过于复杂,绘制UI需要大量的时间
  • 主线程在等锁

帧的创建与交互过程:

在CPU中计算显示内容,包括视图的创建、布局计算、图片解码、文本绘制等。然后CPU将计算好的内容提交到GPU,由GPU进行变换、合成、渲染,并将渲染结果提交到帧缓冲区,等待下一次VSync信号到来时显示到屏幕上。

理想情况下每次 V-Sync 发生时,CPU 以及 GPU 都已经完成了对图像的处理以及绘制,显示器可以直接拿到缓冲区中的帧。即 V-Sync 会按这种方式工作:

但是,如果 CPU 或者 GPU 的处理需要的时间较长,就会发生掉帧的问题,即在 V-Sync 信号发出时,CPU 和 GPU 并没有准备好需要渲染的帧,显示器就会继续使用当前帧。

根据 V-Sync 的原理,优化应用性能、提高 App 的 FPS 就可以从两个方面来入手,优化 CPU 以及 GPU 的处理时间。

帧的量化

每段16.67ms,其实是一个VSync信号,该信号用于保证只有在帧缓冲区中的图像被渲染之后,后备缓冲区中的内容才可以被拷贝到帧缓冲区中

3视图-更新机制.md

在VSync信号到来后,系统图形服务通过CADisplayLink等机制通知App,App主线程开始在CPU中计算显示内容,包括视图的创建、布局计算、图片解码、文本绘制等。然后CPU将计算好的内容提交到GPU,由GPU进行变换、合成、渲染,并将渲染结果提交到帧缓冲区,等待下一次VSync信号到来时显示到屏幕上

iOS默认刷新频率是60HZ,所以GPU渲染只要达到60fps就不会产生卡顿。

只要能使CPU的计算和GPU的渲染能在规定时间内完成,就不会出现卡顿。所以目标是减少CPU和GPU的资源消耗。

一、如何监控卡顿

以下内容摘自:iOS-Monitor-Platform 项目中的 如何监控卡顿

那怎么监控应用的卡顿情况?通常有以下两种方案

  • FPS 监控:这是最容易想到的一种方案,如果帧率越高意味着界面越流畅,上文也给出了计算 FPS 的实现方式,通过一段连续的 FPS 计算丢帧率来衡量当前页面绘制的质量。

  • 主线程卡顿监控:这是业内常用的一种检测卡顿的方法,通过开辟一个子线程来监控主线程的 RunLoop,当两个状态区域之间的耗时大于阈值时,就记为发生一次卡顿。美团的移动端性能监控方案 Hertz 采用的就是这种方式

    主线程卡顿监控的实现思路:开辟一个子线程,然后实时计算 kCFRunLoopAfterWaitingkCFRunLoopBeforeSources 两个状态区域之间的耗时是否超过某个阀值,来断定主线程的卡顿情况。计算的是kCFRunLoopAfterWaiting(被唤醒)到kCFRunLoopBeforeSources(开始处理UI)的时间,而不是kCFRunLoopBeforeSources到kCFRunLoopAfterWaiting的时间

    这个时间代表了 RunLoop 被唤醒后,到真正开始执行 UI 事件的延迟

    计算的是 kCFRunLoopAfterWaiting 到 kCFRunLoopBeforeSources 的时间!


    📌 为什么卡顿计算的是 kCFRunLoopAfterWaiting → kCFRunLoopBeforeSources 的时间?

    🔹 这个时间代表了 RunLoop 被唤醒后,到真正开始执行 UI 事件的延迟。

    如果这个时间超过 0.1s(即 100ms),说明主线程可能被卡住了,导致 UI 响应变慢掉帧


    📌 为什么不是 kCFRunLoopBeforeSources → kCFRunLoopAfterWaiting?

    ❌ 这个时间不是衡量卡顿的关键。

    kCFRunLoopBeforeSources 之后,RunLoop 进入 UI 事件 & 业务逻辑执行阶段,这个时间长短取决于你的代码复杂度。例如:

    ​ • UI 事件的回调

    ​ • 复杂的计算任务

    ​ • 耗时操作(如同步网络请求、数据库操作等)

    但是,这部分时间长不一定意味着“卡顿”,因为:

    ​ 1. 执行业务逻辑本来就可能需要时间

    ​ 2. 如果业务逻辑复杂,可以通过异步优化,但它本身并不是卡顿的判断标准

    ​ 3. 真正的问题是 RunLoop 被唤醒后,为什么 UI 事件迟迟没有开始执行?答:📌 核心原因:主线程在 kCFRunLoopAfterWaiting 之后被其他任务阻塞,导致无法立即执行 UI 事件(Source0)。*

    其他参考文章:iOS 性能监控(二)—— 主线程卡顿监控

更详细内容推荐查看项目中md文档。

二、FPS

FPS”通常指的是”Frames Per Second”,即每秒传输帧数,用于衡量动画或视频渲染的性能。

一般情况下,我们的屏幕刷新率是 1/60s 一次。CADisplayLink 实际上跟平常用的 NSTimer 的用法基本相似,NSTimer 的时间间隔是以秒为单位,而 CADisplayLink 则是使用帧率来作为时间间隔的单位。

计算核心:CADisplayLink是一个能让我们以和屏幕刷新率相同的频率将内容画到屏幕上的定时器。

实践代码:CQDemoKit 的 CQTSFPSView.m

附:其他定时器

iOS:三种常见计时器(NSTimer、CADisplayLink、dispatch_source_t)的使用

1、NSTimer:进入后台时停止

iOS开发:解决App进入后台,倒计时(定时器)不能正常计时的问题

方法一(主流方法):根据记录开始的时间和获取当前时间进行时间差操作进行处理。监听进入前台、进入后台的消息,在进入后台的时候存一下时间戳,停掉定时器(系统会强制停止定时器);在再进入前台时,计算时间差。若剩余的时间大于时间差,就减去时间差,否则赋值剩余时间为0。

方法二:苹果只允许三种情况下的App在后台可以一直执行:音视频、定位更新、下载,若是直播、视频播放、地图类、有下载的应用可以这样使用,但是有些小需求就不需这样做。

方法三:通过向苹果的系统申请,在后台完成一个Task任务。

2、NSTimer:滑动时停止

2内存-②循环引用Timer.md》 中的 【3、NSTimer和NSRunLoop的关系?】

iOS卡顿优化

https://github.com/dvlproad/CJOptimizeProject/blob/main/CJOptimizeProject/TSDemo_Optimize/FPS/TSOptimizeTableViewController.m

我们写个列表,计算高度时候模拟耗时,且加载图片时候不使用缓存。代码示例如下:

image-20241027010604427

则经在Instrument中跑起来可查出耗时的位置即在上述①②。

image-20241027010515562

点击进入也能看到详细的时间分布

image-20241027015737011

二、Leak

https://github.com/dvlproad/CJOptimizeProject/blob/main/CJOptimizeProject/TSDemo_Optimize/Leak/TSLeakViewController.m

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 内存泄漏示例--循环引用导致的
- (void)leakMemoryCircularRreference:(UIButton *)button {
//__weak typeof(self) weakSelf = self;
//__strong __typeof(self) strongSelf = weakSelf;

TSLeakViewController *viewController = [[TSLeakViewController alloc] init];
if ([button.titleLabel.text containsString:@"会泄露"]) {
viewController.leakBlock = ^(NSString * _Nonnull title) {
viewController.title = title; // 会泄露
};
} else {
__weak typeof(viewController) weakViewController = viewController;
viewController.leakBlock = ^(NSString * _Nonnull title) {
//self.title = title; // 不会泄露
weakViewController.title = title; // 不会泄露,使得在 Block 中不会出现对 自身 的强引用

//__strong __typeof(viewController) strongViewController = weakViewController;
//strongViewController.title = title; // 不会泄露
};
}
[self.navigationController pushViewController:viewController animated:YES];
}

ios Instruments之Allocations

ios_alloction_01

Flutter FPS

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Widget buildList(BuildContext context) {
return ListView.builder(
itemCount: 1000,
itemBuilder: (context, index) {
preBuildItem(index); // 模拟耗时操作,例如使用延时

int imageIndex = index % 10;
String imageName = "assets/images/cqts_${imageIndex + 1}.jpg";
return Container(
color: index % 2 == 0 ? Colors.red : Colors.blue,
child: Row(
children: [
JankText("Item $index"),
Image(
image: AssetImage(imageName),
width: 100,
height: 100,
),
],
),
);
},
);

通过进出测试FPS页面,看到有红色区域,点击看到BUILD时长很长,理论上应该是一群绘制,结果在绘制前有一堆控的时间。所以点击BUILD段分析,其中有标记⑦的方法耗时97.28%。即为我们设置进去模拟卡顿的地方。

image-20241029012336176

BUILD分析完已找出第一个造成卡顿的方法。我们继续往BUILD里的JankText分析,发现其也是在绘制Text前有很长一段被其他占用的方法。至此两处耗时操作都找出来了。

image-20241029012559483

内存

image-20241029015507146

flutter_leak_memory_01 image-20241029015611284 image-20241029015635482

通过比较进入后的列表页面的内存情况 main-3 与 未进入前的首页的内存情况 main-2,得到进入到列表后,增加的内存为如下:

image-20241029015741757

通过比较从列表页返回的首页内存情况 main-4 与 未进入前的首页的内存情况 main-2,得到返回到首页后,内存无增加,即正常全部释放:

image-20241029015830539

启动优化

App Launch

image-20241030221807138

寻找卡顿的切入点

监控卡顿,最直接就是找到主线程都在干些啥玩意儿。

我们知道一个线程的消息事件处理都是依赖于NSRunLoop来驱动,所以要知道线程正在调用什么方法,就需要从NSRunLoop来入手

参考文章

iOS 性能监控(一)—— CPU功耗监控

iOS 性能监控(三)—— 方法耗时监控

iOS-卡顿监测-FPS监测

iOS实时卡顿检测-RunLoop(附实例)

性能优化:列表优化

列表优化

其他参考文档:列表无限滚动时,数据如何预加载,从而达到无缝加载的效果 Demo

一、请求时,列表网络数据的预加载

1、第一页的预加载

提前创建 vm 或者 manager 管理请求数据。 (平常用的数据携带、默认数据等本地数据暂不在此讨论)

2、下一页的预加载/无缝加载

预加载是指在Cell还没有出现在屏幕上时,就提前加载它所需的数据和资源。这可以减少Cell出现时的加载时间,提升用户体验。

举例:在用户阅读了最新页码数据的70%(方式1:按视图长度contentSize:UIScrollView所有内容的尺⼨;方式2:按数据长度)时(根据实际情况调节),提前进行下一页数据的加载。这样用户可以省去本来在阅读完已加载的时候需要做一次上拉加载等待数据的过程。

可以看到第一页阈值是70%,即代表进入后即使没滑动也会自动加载第二页。假设每一页都是10条数据,则第一页的阈值是第7条,第二页是第(10+7)条,依次类推…得阈值为 newThreshold = (curPageIndex+0.7)/(curPageIndex+1.0)

image-20240815012027334

图片来源:列表的预加载.graffle

代码实现(方式1:按视图长度contentSize:UIScrollView所有内容的尺⼨):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let currentOffset = scrollView.contentOffset.y + scrollView.frame.size.height
let totalHeight = scrollView.contentSize.height
let ratio = currentOffset / totalHeight // 当前滚动内容占总内容的比例

//let threshold = 0.7 // 优化前
var threshold = (curPageIndex+0.7)/(curPageIndex+1.0) // 优化后,可以看到第一页还是70% 即使进入后没滑动也会自动加载第二页
// 超过阈值 threshold 则进行预加载下一页数据。可以看到第一页阈值是70%,即代表进入后即使没滑动也会自动加载第二页
if ratio >= threshold {
fetchNextPageData(page: currentPage)
}
}

var currentPage = 0
func fetchNextPageData() {
currentPage += 1
loadPage(pageIndex: currentPage)
}


func loadPage(_ pageIndex: int) {

}

参考文章:预加载与智能预加载(iOS)

其他参考文章:

二、请求后,数据渲染时的按需加载

滑动时,按需加载:UITableView禁止或者减速滑动结束的时候,进行异步加载图片,快滑动过程中,只加载目标范围内的Cell。

问:从第1个cell滑动到第100个cell。请问在快速滑动情况下如果在tableView(_:cellForRowAt:) 中打印indexPath,能够打印到1到100的indexPath吗

答:在快速滑动 UITableView 从第一个单元格到第100个单元格时,tableView(_:cellForRowAt:) 方法可能会被多次调用,但并不意味着它会为每个索引路径(从0到99)都打印出对应的值。如果用户滑动得非常快,UITableView 为了保持流畅的滚动性能,可能会跳过一些单元格的 tableView(_:cellForRowAt:) 调用,尤其是那些在屏幕外的单元格。

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
class TableViewController: UITableViewController {
// 标记是否应该加载图片
var shouldLoadImages = false

override func viewDidLoad() {
super.viewDidLoad()
self.tableView.estimatedRowHeight = 100 // 设置预估行高
self.tableView.rowHeight = UITableView.automaticDimension
}

// ... 其他代码
}


func scrollViewDidScroll(_ scrollView: UIScrollView) {
// 计算滚动速度,并根据滚动速度设置是否加载图片,有时候快停止的时候就可以加载了,不用完全停止。
let currentVelocity = scrollView.panGestureRecognizer.velocity(in: scrollView)
shouldLoadImages = abs(currentVelocity.y) > 1.0 ? false : true
}

override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "CellIdentifier", for: indexPath)
// 检查是否应该加载图片(快速滑动过程中,可能有多次调用到该方法)
if shouldLoadImages {
loadImageAsync(for: cell) // 异步加载图片
} else {
cell.imageView?.image = UIImage(named: "placeholder") // 快速滑动时,只加载占位图或者不加载图片
}

// 配置cell的其他内容
// ...

return cell
}

写法二:不太推荐,性质一样,不过写法有点别扭,相当于cell内容的处理位置变了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func scrollViewDidScroll(_ scrollView: UIScrollView) {
// 计算滚动速度,并根据滚动速度设置是否加载图片,有时候快停止的时候就可以加载了,不用完全停止。
let currentVelocity = scrollView.panGestureRecognizer.velocity(in: scrollView)
shouldLoadImages = abs(currentVelocity.y) > 1.0 ? false : true

// 1. 获取当前可见的单元格索引路径数组,再根据索引路径获取对应的单元格
if let visiblePaths = self.tableView.indexPathsForVisibleRows {
for indexPath in visiblePaths {
if let cell = self.tableView.cellForRow(at: indexPath) {
// 检查是否应该加载图片
if shouldLoadImages {
loadImageAsync(for: cell) // 异步加载图片
} else {
cell.imageView?.image = UIImage(named: "placeholder") // 快速滑动时,只加载占位图或者不加载图片
}
}
}
}
}

优化加强:如果滚动方向改变,快速下滑后又上滑,取消可见区域下面的部分(可见区域上面的部分)。类似于 PrefetchDataSource 的 prefetchRowsAtIndexPaths 和 cancelPrefetchingForRowsAtIndexPaths

延伸

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
var shouldLoadImages: Bool = true										// 假设有一个标记来记录是否应该加载图片
var loadingOperations: [IndexPath: Operation] = [:] // 用于存储正在加载的图片的单元格的IndexPath

func scrollViewDidScroll(_ scrollView: UIScrollView) {
// 计算滚动速度,并根据滚动速度设置是否加载图片,有时候快停止的时候就可以加载了,不用完全停止。
let currentVelocity = scrollView.panGestureRecognizer.velocity(in: scrollView)
shouldLoadImages = abs(currentVelocity.y) < 1.0

if shouldLoadImages {
if let visiblePaths = self.tableView.indexPathsForVisibleRows {
prefetchRows(at: visiblePaths)
}
} else {
// 如果不需要加载图片,取消预加载
cancelPrefetchingForRows(in: scrollView)
}
}

func prefetchRows(at indexPaths: [IndexPath]) {
// 调用预加载方法
self.tableView.prefetchRows(at: indexPaths)
}

func cancelPrefetchingForRows(in scrollView: UIScrollView) {
// 检查当前滚动速度和方向
let currentVelocity = scrollView.panGestureRecognizer.velocity(in: scrollView)
let isScrollingUp = currentVelocity.y < 0

// 找出所有不在可视区域内的单元格indexPaths
guard let visiblePaths = self.tableView.indexPathsForVisibleRows else { return }
let indexPathsToCancel = loadingOperations.keys.filter { !visiblePaths.contains($0) }

// 如果滚动方向改变,取消不在可视区域内的单元格的预加载
if isScrollingUp || !visiblePaths.contains(where: { $0 >= indexPathsToCancel.first! }) {
cancelPrefetchingForRows(at: indexPathsToCancel)
}
}

// 实现 UITableViewDataSourcePrefetching 协议的方法
extension YourTableViewController: UITableViewDataSourcePrefetching {
func tableView(_ tableView: UITableView, prefetchRowsAtIndexPaths indexPaths: [IndexPath]) {
// 在这里执行图片的预加载操作
for indexPath in indexPaths {
if shouldLoadImages, let cell = tableView.cellForRow(at: indexPath) as? YourTableViewCell {
loadImageAsync(for: cell, at: indexPath)
}
}
}

func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAtIndexPaths indexPaths: [IndexPath]) {
// 在这里取消图片的预加载操作
for indexPath in indexPaths {
if let operation = loadingOperations[indexPath], !operation.isFinished {
operation.cancel()
}
}
}
}

// 异步加载图片的示例方法
func loadImageAsync(for cell: YourTableViewCell, at indexPath: IndexPath) {
// 执行异步图片加载操作,例如从网络下载
// 这里应该使用你的图片加载逻辑
let operation = BlockOperation {
// 模拟异步图片加载
guard let downloadedImage = UIImage() else { return }

// 将下载的图片缓存到内存中
DispatchQueue.main.async {
cell.imageView.image = downloadedImage
}
}

// 将操作添加到后台队列
operationQueue.addOperation(operation)

// 记录这个操作
loadingOperations[indexPath] = operation
}

func cancelPrefetchingForRows(at indexPaths: [IndexPath]) {
// 调用tableView的取消预加载方法
self.tableView.cancelPrefetchingForRows(at: indexPaths)
}

其他参考文档:

三、渲染时候的优化

1、ASDK

Texture 拥有自己的一套成熟布局方案,虽然学习成本略高,但至少比原生的 AutoLayout 写起来舒服,重点是性能远好于 AutoLayout

ASDK(Texture)主要处理的是 CALayer 而不是 UIView,其核心机制是基于 CALayer 进行异步绘制异步布局,减少 UIView 相关的 CPU 开销,提高 UI 性能。

参考文档:

iOS 开发一定要尝试的 Texture(ASDK)

iOS原生开发视角下的复杂列表开发与性能优化

四、UITableView的性能优化

< 返回目录

参考资料:UITableView性能优化,超实用

①Cell重用:避免频繁创建和销毁,提高滚动流畅度。

1
2
3
4
5
6
// 返回Cell的代理方法会调用很多次,为防止重复创建,我们使用static 保证只创建一次reuseID,提高性能
static NSString *reuseID = “reuseCellID”;

// 从缓存池中取相应identifier的Cell并更新数据。
// 如果没有,才开始alloc新的Cell,并用identifier标识Cell。每个Cell都会注册一个identifier(重用标识符)放入缓存池,当需要调用的时候就直接从缓存池里找对应的id,当不需要时就放入缓存池等待调用。(移出屏幕的Cell才会放入缓存池中,并不会被release)
UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:reuseID];

附:比较

1
2
3
4
5
6
7
8
9
10
11
12
13
缓存池获取可重用Cell两个方法的区别

-(nullable __kindof UITableViewCell *)dequeueReusableCellWithIdentifier:(NSString *)identifier;
// 方法1:这个方法会查询可重用Cell,如果注册了原型Cell,能够查询到,否则,返回nil;而且需要判断if(cell == nil),才会创建Cell,不推荐

-(__kindof UITableViewCell *)dequeueReusableCellWithIdentifier:(NSString *)identifier forIndexPath:(NSIndexPath *)indexPath NS_AVAILABLE_IOS(6_0);
// 方法2:使用这个方法之前,必须通过xib(storyboard)或是Class(纯代码)注册可重用Cell,而且这个方法一定会返回一个Cell

// 附:方法2需要的注册Cell的方法
- (void)registerNib:(nullable UINib *)nib forCellReuseIdentifier:(NSString *)identifier NS_AVAILABLE_IOS(5_0);
- (void)registerClass:(nullable Class)cellClass forCellReuseIdentifier:(NSString *)identifier NS_AVAILABLE_IOS(6_0);

// 好处:如果缓冲区 Cell 不存在,会使用原型 Cell 实例化一个新的 Cell,不需要再判断,同时代码结构更清晰。

②定义一种(尽量少)类型的Cell及善用hidden隐藏(显示)subviews。即可以初始化时就添加,然后通过hide来控制是否显示。(比如朋友圈),而不要用addView给Cell动态添加View,

③提前计算并缓存Cell的高度;(Model去缓存,或者使用SDAutoLayout工具)
④网络数据的异步加载(如cell中的图片加载),不要阻塞主线程;
⑤滑动时,按需加载,常见于大量图片时候。即当UITableView静止或者减速滑动结束之后才去进行异步加载图片。
⑥渲染优化:减少subviews的个数和层级;对于不透明的View,设置opaque为YES;阴影绘制及性能优化。

更新时候:使用局部更新,如果只是更新某组的话,使用reloadSection进行局部更新

1、Cell的重用

基于Cell的重用,真正运行时铺满屏幕所需的Cell数量大致是固定的,设为N个。所以
①如果如果只有一种Cell,那就是只有N个Cell的实例;
②但是如果有M种Cell,那么运行时最多可能会是“M x N = MN”个Cell的实例;
虽然可能并不会占用太多内存,但是能少点不是更好吗。

四、列表加载图片的优化

1、缩略图的使用

图片划分两个地址.一个地址获取缩略图,一个地址获取原图>> 这样你就可以在TableViewCell使用缩略图(展示用),点击图片查看(使用原图). 这样就大大减少了内存的使用.

2、UITableView优化

更轻量的 View Controllers 把 Data Source 和其他 Protocols 分离出来

各页面的预加载

以上内容为同一页面内的预加载处理。那如果是页面间的呢?

在iOS中,使用UITabBarController作为应用程序的主视图控制器时,通常会有几个子视图控制器与之关联。

问:那么这些子视图控制器的初始化和内容渲染分别是在什么时候?或者说刚启动app时候让app在默认的第一个tab,此时第二tab的视图控制器加载了哪些方法,其其他方法又是什么时候触发的。

答:①第二个tab的初始化即init ,在UITabBarController被初始化时,并被设置为窗口(UIWindow)的根视图控制器之后执行。附其他tab的init也一样。

②第二个tab的viewDidload,在 UITabBarController 切换到该tab 时候才会调用。

以上验证代码可在 https://github.com/dvlproad/001-UIKit-CQDemo-iOS 中验证

问1:UITabBarController下,如何预加载指定的视图控制器?

答:数据通过 vm 或者 manager 提前在UITabBarController初始化时进行获取。

问2:UITabBarController下的子视图控制器中如果还有多tab(在顶部),则又如何进行预加载。

答:同1一样。数据通过 vm 或者 manager 提前在该子视图控制器初始化时进行获取。

Flutter的长列表优化

ListView.builder会按需构建列表元素,也就是只有那些可见得元素才会调用itemBuilder 构建元素

性能优化:耗电量、耗流量等其他优化

性能优化

目录

帧率优化?卡顿优化?

Flutter DevTools 的视频使用教程:轻松调试和提高 APP 性能

## 一、耗电量、耗流量优化 > [< 返回目录](#目录)

通过

1
2
3
4
5
①优化位置服务(尽量降低定位精度)、
②网络操作(减少传输、压缩数据、缓存数据)、
③任务处理(减少任务处理量、按需处理,常见于一些后台任务的处理,比如不需要计算里程时候,鹰眼服务可以先关闭)、
④内容更新(减少app使用的视图数量、去除不必要的内容更新)、
⑤定时器(降低触发频率、及时关闭不再需要的重复性定时器)

优化参考文章:iOS进阶–App功耗优化看这篇就够了

定位服务:按需取用,定位频率该降低降低,该关闭关闭

根据位置特性对静止不动的点、位置变化小的点、位置变化大的点,结合不同业务决定位置上报情况,减少不必要的上报,降低耗电量与节省流量。

相似问题参考:解决iOS地图持续定位耗电问题

网络请求优化的

这个需要和后台API一起优化,尽量减少不必要的请求,比如一次API请求尽量把客户端要用到的数据都返回过来,而不是要通过多个请求去返回,同时最好注意数据分页,不要几万条数据都扔给客户端了。

CPU:

使用 Instruments 中的 Time Profile 时间分析工具用来检测应用CPU的使用情况。 定位 app 使用过程中占用高CPU、耗时长的地方。

使用方法参考:Instrument 的 Time Profiler总结

Timer:合理使用Timer数和Timer时间间隔,不宜太短,满足需求即可

设置上报检测计时器(一般设为1秒,但合理的是取不同业务上报频率的最大公约数,比如报班状态下需要5秒上传一次位置,未报班状态只需要20上传一次位置,则取5秒)

合理使用线程,线程适量,不宜过多,不要阻塞主线程

太多线程会导致消耗大量内存(在iOS中,如果把需要消耗大量时间的操作放在主线程上面,会妨碍主线程中被称为RunLoop的主循环的执行,从而导致不能更新用户界面、应用程序的画面长时间停滞等问题。)。

优化算法,减少循环次数

还有关键的就是图片尺寸了,最好客户端需要啥尺寸,服务端就直接给啥尺寸,而不是到客户端上再缩放。

## 二、其他优化 ##### 问题:优化多线程处理,改善多线程嵌套严重,请求耗时的问题 解决:优化多线程处理,改善多线程嵌套严重,请求耗时的问题。

详细:原本项目,采用多线程嵌套的同步方式处理多个线程请求到数据后,再执行最后操作。经优化多线程处理为异步执行时,改善了多线程嵌套严重,请求耗时的问题。

定时器使用的优化

问题:定时器多,其在主线程

为什么要在非主线程创建NSTimer

将 timer 添加到主线程的Runloop里面本身会增加线程负荷
如果主线程因为某些原因阻塞卡顿了,timer 定时任务触发的时间精度肯定也会受到影响
有些定时任务不是UI相关的,本来就没必要在主线程执行,给主线程增加不必要的负担。当然也可以在定时任务执行时,手动将任务指派到非主线程上,但这也是有额外开销的。

iOS 应用性能调优其他参考:

耗时(instruments的Time Profiler)、卡顿(主线程)

iOS app性能优化的那些事

iOS应用性能调优的25个建议和技巧

四、渲染

参考文章:iOS 渲染原理解析

1、渲染原理CPU 与 GPU

  • CPU(Central Processing Unit):现代计算机整个系统的运算核心、控制核心。
  • GPU(Graphics Processing Unit):可进行绘图运算工作的专用微处理器,是连接计算机和显示终端的纽带。

GPU 的渲染流程图

GPU 的渲染流程图

1、Application 应用处理阶段:得到图元

这个阶段具体指的就是图像在应用中被处理的阶段,此时还处于 CPU 负责的时期。在这个阶段应用可能会对图像进行一系列的操作或者改变,最终将新的图像信息传给下一阶段。这部分信息被叫做图元(primitives),通常是三角形、线段、顶点等。

2、Geometry 几何处理阶段:处理图元

3、Rasterization 光栅化阶段:图元转换为像素

光栅化的主要目的是将几何渲染之后的图元信息,转换为一系列的像素,以便后续显示在屏幕上。这个阶段中会根据图元信息,计算出每个图元所覆盖的像素信息等,从而将像素划分成不同的部分。

img

一种简单的划分就是根据中心点,如果像素的中心点在图元内部,那么这个像素就属于这个图元。如上图所示,深蓝色的线就是图元信息所构建出的三角形;而通过是否覆盖中心点,可以遍历出所有属于该图元的所有像素,即浅蓝色部分。

4、Pixel 像素处理阶段:处理像素,得到位图

经过上述光栅化阶段,我们得到了图元所对应的像素,此时,我们需要给这些像素填充颜色和效果。所以最后这个阶段就是给像素填充正确的内容,最终显示在屏幕上。这些经过处理、蕴含大量信息的像素点集合,被称作位图(bitmap)。

2. 屏幕成像与卡顿

屏幕撕裂 Screen Tearing

CPU+GPU 的渲染流程是一个非常耗时的过程。如果在电子束开始扫描新的一帧时,位图还没有渲染好,而是在扫描到屏幕中间时才渲染完成,那么已扫描的部分和未扫描的部分就不是同一帧图像,这就造成屏幕撕裂。

解决屏幕撕裂、提高显示效率的一个策略就是使用垂直同步信号 Vsync 与双缓冲机制 Double Buffering。

屏幕卡顿的本质

手机使用卡顿的直接原因,就是掉帧。前文也说过,屏幕刷新频率必须要足够高才能流畅。对于 iPhone 手机来说,屏幕最大的刷新频率是 60 FPS,一般只要保证 50 FPS 就已经是较好的体验了。但是如果掉帧过多,导致刷新频率过低,就会造成不流畅的使用体验。

CALayer 与 UIView 的关系

当我们创建一个 UIView 的时候,UIView 会自动创建一个 CALayer,为自身提供存储 bitmap 的地方(也就是前文说的 backing store),并将自身固定设置为 CALayer 的代理。

核心关系

  1. CALayer 是 UIView 的属性之一,负责渲染和动画,提供可视内容的呈现。
  2. UIView 提供了对 CALayer 部分功能的封装,同时也另外负责了交互事件的处理。

有了这两个最关键的根本关系,那么下面这些经常出现在面试答案里的显性的异同就很好解释了。举几个例子:

  • 相同的层级结构:我们对 UIView 的层级结构非常熟悉,由于每个 UIView 都对应 CALayer 负责页面的绘制,所以 CALayer 也具有相应的层级结构。
  • 部分效果的设置:因为 UIView 只对 CALayer 的部分功能进行了封装,而另一部分如圆角、阴影、边框等特效都需要通过调用 layer 属性来设置。
  • 是否响应点击事件:CALayer 不负责点击事件,所以不响应点击事件,而 UIView 会响应。
  • 不同继承关系:CALayer 继承自 NSObject,UIView 由于要负责交互事件,所以继承自 UIResponder。

当然还剩最后一个问题,为什么要将 CALayer 独立出来,直接使用 UIView 统一管理不行吗?为什么不用一个统一的对象来处理所有事情呢?

这样设计的主要原因就是为了职责分离,拆分功能,方便代码的复用。通过 Core Animation 框架来负责可视内容的呈现,这样在 iOS 和 OS X 上都可以使用 Core Animation 进行渲染。与此同时,两个系统还可以根据交互规则的不同来进一步封装统一的控件,比如 iOS 有 UIKit 和 UIView,OS X 则是AppKit 和 NSView。

图片库SDWebImage①缓存-①NSCache

一、NSCache的认识

1、为什么内存缓存要基于 NSCache?

NSCache和NSMutableDictionary,它们是iOS中常用的两个缓存类,基本上相同,都是健-值形式的内存缓存方式

1、NSCache的使用很方便,提供了类似可变字典的使用方式,但它比可变字典更适用于实现缓存,最重要的原因为**NSCache是线程安全的,使用NSMutableDictionary自定义实现缓存时需要考虑加锁和释放锁**,NSCache已经帮我们做好了这一步,即在开发者自己不编写加锁代码的前提下,多个线程便可以同时访问NSCache。

2、其次,在内存不足时NSCache会自动释放存储的对象,不需要手动干预,如果是自定义实现需要监听内存状态然后做进一步的删除对象的操作。

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
@interface NSCache <KeyType, ObjectType> : NSObject {
@private
id _delegate;
void *_private[5];
void *_reserved;
}

@property (copy) NSString *name;

@property (nullable, assign) id<NSCacheDelegate> delegate;

- (nullable ObjectType)objectForKey:(KeyType)key;
- (void)setObject:(ObjectType)obj forKey:(KeyType)key; // 0 cost
- (void)setObject:(ObjectType)obj forKey:(KeyType)key cost:(NSUInteger)g;
- (void)removeObjectForKey:(KeyType)key;

- (void)removeAllObjects;

// NSCache可以指定缓存的限额,当缓存超出限额自动释放内存
// ①对象缓存可持有最大的数量 ,默认是0 没有限制),一旦超出限额,会自动删除之前添加的缓存数据
@property NSUInteger countLimit; // limits are imprecise/not strict
// ②缓存中可持有的最大空间 默认是0(没有限制)
@property NSUInteger totalCostLimit; // limits are imprecise/not strict

// 管理丢弃内容
// 是否可以自动缓存清除可丢弃的内容,默认是YES
@property BOOL evictsObjectsWithDiscardedContent;

@end

3、还有一点就是**NSCachekey不需要实现NSCopying协议**,因为NSCache的键key不会被复制/拷贝。在键key不支持拷贝操作的情况下,该类用起来比字典更方便。

2、NSCache什么时候会删除缓存中的对象

NSCache删除缓存中的对象会在以下情形中发生:

  • NSCache缓存对象自身被释放
  • 手动调用removeObjectForKey:removeAllObjects方法
  • 缓存中对象的个数大于countLimit,或缓存中对象的总cost值大于totalCostLimit
  • 程序进入后台后
  • 收到系统的内存警告

二、SDMemoryCache的认识

以上已说明内存缓存要基于 NSCache,所以SDMemoryCache要继承于NSCache,源码如下:

1
2
3
4
5
6
7
8
9
10
// SDMemoryCache.h

/**
A memory cache which auto purge the cache on memory warning and support weak cache.
*/
@interface SDMemoryCache <KeyType, ObjectType> : NSCache <KeyType, ObjectType> <SDMemoryCache> // 请注意这里除继承 NSCache <KeyType, ObjectType> 外,还有一个 与 SDMemoryCache 类名同名的 <SDMemoryCache> 协议

@property (nonatomic, strong, nonnull, readonly) SDImageCacheConfig *config;

@end

可以看出类@interface SDMemoryCache还要遵守<SDMemoryCache>协议。虽然类@interface SDMemoryCache没提供什么方法,但与 SDMemoryCache 类名同名的<SDMemoryCache>协议提供了。

<SDMemoryCache>协议源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
A protocol to allow custom memory cache used in SDImageCache.
*/
@protocol SDMemoryCache <NSObject>

@required

- (nonnull instancetype)initWithConfig:(nonnull SDImageCacheConfig *)config;

- (nullable id)objectForKey:(nonnull id)key;

- (void)setObject:(nullable id)object forKey:(nonnull id)key;

- (void)setObject:(nullable id)object forKey:(nonnull id)key cost:(NSUInteger)cost;

- (void)removeObjectForKey:(nonnull id)key;
- (void)removeAllObjects;

@end

1、为什么另外建了个与类名SDMemoryCache同名的<SDMemoryCache>协议,并把方法提到了<SDMemoryCache>协议中?

答:为了当你想要要使用自定义的缓存类的时候,可以不用继承@interface SDMemoryCache,而只需遵循<SDMemoryCache>协议的方便。

设计模式-①概览.md

// 开闭原则:对扩展开放,对修改封闭.
// 里氏替换原则:应用程序中任何父类对象出现的地方,我们都可以用其子类的对象来替换,并且可以保证原有程序的逻辑行为和正确性。因为这里父类是抽象类,所以肯定遵守里氏替换原则。

2、内存缓存SDMemoryCache、磁盘缓存SDDiskCache的基类各是什么?

答:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 内存类
@interface SDMemoryCache <KeyType, ObjectType> : NSCache <KeyType, ObjectType> <SDMemoryCache>

@end


// 磁盘缓存类
@interface SDDiskCache : NSObject <SDDiskCache>

@property (nonatomic, strong, readonly, nonnull) SDImageCacheConfig *config;

- (void)moveCacheDirectoryFromPath:(nonnull NSString *)srcPath toPath:(nonnull NSString *)dstPath;

@end

三、内存缓存的设计

1、SDImageCache

id<SDMemoryCache> memoryCache;id<SDDiskCache> diskCache;

一个SDImageCache对象,由内存和磁盘共同控制缓存。控制的策略由SDImageCacheConfig类来定制。

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
@interface SDImageCache : NSObject

#pragma mark - Properties
@property (nonatomic, copy, nonnull, readonly) SDImageCacheConfig *config;

@property (nonatomic, strong, readonly, nonnull) id<SDMemoryCache> memoryCache;
@property (nonatomic, strong, readonly, nonnull) id<SDDiskCache> diskCache;

@property (nonatomic, copy, nonnull, readonly) NSString *diskCachePath;

@property (nonatomic, copy, nullable) SDImageCacheAdditionalCachePathBlock additionalCachePathBlock;

#pragma mark - Singleton and initialization

/**
* Returns global shared cache instance
*/
@property (nonatomic, class, readonly, nonnull) SDImageCache *sharedImageCache;

/**
* Init a new cache store with a specific namespace
*
* @param ns The namespace to use for this cache store
*/
- (nonnull instancetype)initWithNamespace:(nonnull NSString *)ns;

同时SDImageCache还有个类目

1
2
3
4
5
6
7
8
9
// SDImageCache.h
@interface SDImageCache (SDImageCache) <SDImageCache>

@end

// SDImageCacheDefine.h
@protocol SDImageCache <NSObject>

@end

内存和磁盘共同控制缓存策略定制类SDImageCacheConfig

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
// SDImageCacheConfig.h
@interface SDImageCacheConfig : NSObject <NSCopying>

// 是否使用内存做缓存,默认为YES
@property (assign, nonatomic) BOOL shouldCacheImagesInMemory;

@property (assign, nonatomic) BOOL shouldRemoveExpiredDataWhenEnterBackground;

// 缓存图片的最长时间,单位是秒,默认是缓存一周
@property (assign, nonatomic) NSTimeInterval maxDiskAge;
// 缓存占用最大的空间,单位是字节
@property (assign, nonatomic) NSUInteger maxDiskSize;


@property (assign, nonatomic) NSUInteger maxMemoryCost;
@property (assign, nonatomic) NSUInteger maxMemoryCount;

/*
* The attribute which the clear cache will be checked against when clearing the disk cache
* Default is Modified Date
*/
@property (assign, nonatomic) SDImageCacheConfigExpireType diskCacheExpireType;



@end

2、SDImageCachesManager

管理NSArray<id> *caches;

一张图片就是一份SDImageCache元素对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
A caches manager to manage multiple caches.
*/
@interface SDImageCachesManager : NSObject <SDImageCache>

@property (nonatomic, class, readonly, nonnull) SDImageCachesManager *sharedManager;

@property (nonatomic, assign) SDImageCachesManagerOperationPolicy queryOperationPolicy;
@property (nonatomic, assign) SDImageCachesManagerOperationPolicy storeOperationPolicy;
@property (nonatomic, assign) SDImageCachesManagerOperationPolicy removeOperationPolicy;
@property (nonatomic, assign) SDImageCachesManagerOperationPolicy containsOperationPolicy;
@property (nonatomic, assign) SDImageCachesManagerOperationPolicy clearOperationPolicy;

@property (nonatomic, copy, nullable) NSArray<id<SDImageCache>> *caches; // 一张图片就是一份SDImageCache元素对象。

- (void)addCache:(nonnull id<SDImageCache>)cache;
- (void)removeCache:(nonnull id<SDImageCache>)cache;

@end

END

图片库SDWebImage①缓存-②缓存原理

七、多线程–第三方库SDWebImage

参考资料:

这个类库提供一个UIImageView类别以支持加载来自网络的远程图片。具有缓存管理、异步下载、同一个URL下载次数控制和优化等特征。

基本思路

SDWebImage 基本思路如下:

SDWebImage基本思路

1 扩展(category) UIImageView,这样写出的代码更整洁

2 GCD 异步下载

3 重用 UITableViewCell 加异步下载会出现图片错位,所以每次 cell 渲染时都要预设一个图片 (placeholder),

以覆盖先前由于 cell 重用可能存在的图片, 同时要给 UIImageView 设置 tag 以防止错位。

4 内存 + 文件 二级缓存, 内存缓存基于 NSCache

暂时没有考虑 cell 划出屏幕的情况,一是没看明白 SDWebImage 是怎么判断滑出屏幕并 cancel 掉队列中对应的请求的

二是我觉得用户很多情况下滑下去一般还会滑回来,预加载一下也挺好。坏处是对当前页图片加载性能上有点小影响。

1、SDWebImage在图片下载及缓存的处理方法

SDWebImage加载网络图片的原理图分析如下:
SDWebImage加载网络图片的原理图分析

其中图片的获取与存储过程大概如下:
SDWebImage theory

大概描述为:
注:SDWebImage中的SDWebImageDownloader有使用到GCD相关dispatch_barrier_sync
1)当我门需要获取网络图片的时候,我们首先需要的便是URl没有URl什么都没有,获得URL后我们SDWebImage实现的并不是直接去请求网路,而是检查图片缓存中有没有和URl相关的图片,如果有则直接返回image,如果没有则进行下一步。

2)当图片缓存中没有图片时,SDWebImage依旧不会直从网络上获取,而是检查沙盒中是否存在图片,如果存在,则把沙盒中对应的图片存进image缓存中,然后按着第一步的判断进行。

3)如果沙盒中也不存在,则显示占位图,然后根据图片的下载队列缓存判断是否正在下载,如果下载则等待,避免二次下载。如果不存则创建下载队列,下载完毕后将下载操作从队列中清除,并且将image存入图片缓存中。

4)刷新UI(当然根据实际情况操作)将image存入沙盒缓存。

缓存策略的源码解析:SDWebImage 源码解析—缓存策略

END

图片库SDWebImage①缓存-③缓存不更新问题

一、背景

后台图片内容换了,但是url还是老的,手机就用了缓存,没有从后台更新图片。

主要问题表现在哪里呢?
很多app都有用户的概念,用户一般都会有头像,基本上都上传到服务器上,而服务器往往也支持在pc端更新头像(比如微博、QQ等)。
如果你的头像使用SDWebImage设置的,那么你会发现,pc端更新头像后,客户端可能(往往)不会自动更新!!!

二、解决

问:使用SDWebImage如何加载url不变,但图片已经变化的图片。

答:解决方法可以有如下几种:

  1. 让服务器更新url,也就是说服务器端如果更新了头像,那么就生成新的url(推荐)

    后台给的url中增加字段,表示图片是否更新,比如增加一个timestamp字段.图片更新了,就更新下这个字段;
    对客户端来说,只要这个timestamp字段变了,整个url就不一样了,就会从网络取图片。比如http://xxx/xx? timestamp=xxx
    也可以添加图片文件的md5来表示文件是否更新,比如http://xxx/xx? md5=xxx。并且md5比时间戳要好,这是强校验。时间戳在服务器回滚或者服务器重启的时候会有特殊的逻辑。不过大多数时候时间戳也够用了。
    ====这个方案客户端不用改,后台改动也不会太大。====强烈推荐

  2. 客户端只使用内存缓存,不使用磁盘缓存,那么下次启动时候就会重新下载,从而得到最新的了。(缺点:本次使用过程中没能看到最新图片。且即使之后下载到最新图片了,由于只使用内存缓存,不使用磁盘缓存。导致程序关闭又打开之后,缓存就没了,需要访问网络,重新加载图片)

SDWebImageCacheMemoryOnly这个参数对解决这个问题有帮助,只用内存缓存,不用磁盘缓存,App关了再开,肯定会重新下载,不会出现服务器和手机缓存图片不一致的情况。

  1. 客户端使用SDWebImageRefreshCached,同时让服务器端支持cache-control。

    SDWebImageRefreshCached,这个参数就是为了解决url没变但是服务器图片改变的问题,很适合当前的场景。方案就是磁盘缓存不自己实现了,直接使用NSURLCache。记得AFNetworking的大神Matt就曾经嘲笑过SDWebImage的缓存是多此一举,还不如系统的NSURLCache好用。

    SDWebImageRefreshCached参数设置之后,会怎么样?

    • 不使用SDWebImage提供的内存缓存和硬盘缓存

    • 采用NSURLCache提供的缓存,默认情况下有效时间只有5秒

    • 图片不一致的问题是解决了,不过效果跟不使用缓存差别不大。个人建议这个参数还是不要用为好,为了一个小特性,丢掉了SDWebImage最核心的特色。

      1
      2
      3
      [imageview sd_setImageWithURL:[NSURL URLWithString:url]	
      placeholderImage:nil
      options:SDWebImageRefreshCached];
  2. 不使用SDWebImage,自己控制缓存,用系统API实现(NSURLCache)实现。(缺点还是和3一样,得服务端配合更改。)

    主要也是和使用SDWebImageRefreshCached时候一样,会涉及到Cache-Control(设定缓存有效时间,默认是5s)Last-Modified/If-Modified-Since(时间戳)Etag/If-None-Match(标签,一般用MD5值)

附:其他options

SDWebImageRetryFailed表示就算下载失败也会再次尝试(不把下载失败的的url加入黑名单)

参考文章:

图片库SDWebImage②请求-①简介

2、SDWebImageDownloader 异步的图片下载器

SDWebImageDownloader是一个异步的图片下载器,它是一个单例类,主要负责图片的下载操作的管理。图片的下载是放在一个NSOperationQueue操作队列中来完成的,默认情况下,队列最大并发数是6。如果需要的话,我们可以通过SDWebImageDownloader类的maxConcurrentDownloads属性来修改。其声明如下:

3、SDWebImageDownloaderOperation 下载操作

下面我们来说一说SDWebImage的下载操作SDWebImageDownloaderOperation。该类继承自NSOperation,并且采用了 SDWebImageDownloaderOperationInterface, SDWebImageOperation, NSURLSessionTaskDelegate, NSURLSessionDataDelegate 四个协议方法。

①、SDWebImageDownloaderOperation的下载请求

先通过URL等生成NSURLRequest,并设置给operation属性。

1
2
3
4
5
6
7
声明:
@property (strong, nonatomic, readonly, nullable) NSURLRequest *request;

定义:
NSMutableURLRequest *request = [[NSMutableURLRequest alloc] initWithURL:url
cachePolicy:cachePolicy
timeoutInterval:timeoutInterval];

有时候下我们希望它支持后台下载。所以在operation的start方法中,如果支持后台shouldContinueWhenAppEntersBackground,则将当前请求添加到后台任务中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Class UIApplicationClass = NSClassFromString(@"UIApplication");
BOOL hasApplication = UIApplicationClass && [UIApplicationClass respondsToSelector:@selector(sharedApplication)];
if (hasApplication && [self shouldContinueWhenAppEntersBackground]) {
__weak __typeof__ (self) wself = self;
UIApplication * app = [UIApplicationClass performSelector:@selector(sharedApplication)];
self.backgroundTaskId = [app beginBackgroundTaskWithExpirationHandler:^{
__strong __typeof (wself) sself = wself;

if (sself) {
[sself cancel];

[app endBackgroundTask:sself.backgroundTaskId];
sself.backgroundTaskId = UIBackgroundTaskInvalid;
}
}];
}

对于图片的下载,SDWebImageDownloaderOperation的下载使用NSURLSession类。

1
self.dataTask = [session dataTaskWithRequest:self.request];
1
2
3
4
5
6
7
8
9
10
11
12
if (self.dataTask) {
[self.dataTask resume];

......
// 任务开始后,会在主线程抛出下载开始通知
__weak typeof(self) weakSelf = self;
dispatch_async(dispatch_get_main_queue(), ^{
[[NSNotificationCenter defaultCenter] postNotificationName:SDWebImageDownloadStartNotification object:weakSelf];
});
} else {
......
}

END

图片库SDWebImage②请求-②避免重复请求问题

iOS 如何避免在短时间内频繁发出相同的网络请求?

SDWebImage框架特征

  • 类别UIImageView,UIButton,MKAnnotationView- - 添加Web图像和高速缓存管理
  • 异步图像下载器
  • 具有自动缓存到期处理的异步内存+磁盘映像缓存
  • 背景图像解压缩
  • 保证相同的URL不会被下载多次
  • 保证虚假网址不会重复重试
  • 保证主线程永远不会被阻止
  • 使用GCD和ARC

一、SDWebImage是如何避免在短时间内频繁发出相同的网络请求?

SDWebImage是如何避免在短时间内频繁发出相同的网络请求?

设想:一个列表所有的图片请求都是同一个url。

答:

1、如何确保不生成重复的网络请求

图片下载的回调信息存储在SDWebImageDownloader类的URLOperations属性中,该属性是一个字典,key是图片的URL地址,value则是一个SDWebImageDownloaderOperation对象,包含每个图片的多组回调信息。由于我们允许多个图片同时下载,因此可能会有多个线程同时操作URLOperations属性。需要保证URLOperations操作(添加、删除)的线程安全性。

为了避免同一个URL在被多次任务请求的时候,进行多次的重复网络下载。

1、将下载地址URL与其对应的网络下载请求,通过下载管理器SDWebImageDownloader的URLOperations属性管理起来。(该属性是一个字典,key是图片的URL地址,value是operation)

2、我们并不会对每次操作一进来就进行创建请求任务,而是先通过之前缓存的下载任务URLOperations,通过URL寻找是否有该操作了,①如果有则不创建,而是直接取出来使用;②如果没有才创建operation来使用,并添加到URLOperations中。以备后续有同样URL请求的时候,能会从URLOperations中得到operation,就不会导致重复创建和添加到队列中了。(通过URLOperations,我们以此保证一个URL同时在被请求多次的情况下,生成/取到的是同一个,也只有一个SDWebImageDownloaderOperation,从而也就只会被下载一次。)。

在SDWebImage版本5.8.1中的相关代码如下:

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
SD_LOCK(self.operationsLock);	// 6、加锁,防止添加过程中,又有数据需要添加,保证线程安全
id downloadOperationCancelToken;
NSOperation<SDWebImageDownloaderOperation> *operation = [self.URLOperations objectForKey:url]; // 1、从进行中的 URLOperations 获取
if (!operation || operation.isFinished || operation.isCancelled) {
operation = [self createDownloaderOperationWithUrl:url options:options context:context]; // 2、如果之前没有下载操作,则创建,并加入到 URLOperations 中
// ....省略一堆代码
@weakify(self);
operation.completionBlock = ^{
@strongify(self);
if (!self) {
return;
}
SD_LOCK(self.operationsLock);
[self.URLOperations removeObjectForKey:url]; // 3、下载完成后,记得删除操作
SD_UNLOCK(self.operationsLock);
};
self.URLOperations[url] = operation; // 4、保存到 URLOperations 中,用于判断
downloadOperationCancelToken = [operation addHandlersForProgress:progressBlock completed:completedBlock];
[self.downloadQueue addOperation:operation]; // 4、添加操作

} else {
// ....省略一堆代码
@synchronized (operation) {
// 5、如果之前有下载操作,则不用创建操作,但是仍然需要保存回调,不然其他位置的图片的请求就丢失了(虽然说它们是同一张图片)
downloadOperationCancelToken = [operation addHandlersForProgress:progressBlock completed:completedBlock];
}
}
SD_UNLOCK(self.operationsLock); // 6、解锁

3、问:重复的url请求,没有创建operation了,那operation的回调怎么办?

答:虽然重复的URL只有一个SDWebImageDownloaderOperation。但是这个opeartion的callbackBlocks是个数组,所有的回调都用这个数组保存起来的,所以不会丢失的(这里callbackBlocks数组里存放字典,对应进行时候的ProgressCallback回调和结束时候的CompletedCallback回调)。

4、问:对同一个operation的callbackBlocks数组操作,有什么要注意的?

答:因为不管同个url不管有没有新的operation生成,我们都会有一个当前任务对应downloadOperationCancelToken生成,来给这个operation添加回调。即同一个url所对应opeartion里的callbackBlocks数组,在有多次图片任务时候,就会有多次操作callbackBlocks的情况。所以对callbackBlocks的操作,也要保证线程安全。

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
// SDWebImageDownloader.m

- (nullable SDWebImageDownloadToken *)downloadImageWithURL:(nullable NSURL *)url
options:(SDWebImageDownloaderOptions)options
context:(nullable SDWebImageContext *)context
progress:(nullable SDWebImageDownloaderProgressBlock)progressBlock
completed:(nullable SDWebImageDownloaderCompletedBlock)completedBlock {
// ....省略一堆代码

SD_LOCK(self.operationsLock);
id downloadOperationCancelToken;
NSOperation<SDWebImageDownloaderOperation> *operation = [self.URLOperations objectForKey:url];
if (!operation || operation.isFinished || operation.isCancelled) {
// ....省略一堆代码
} else {
// When we reuse the download operation to attach more callbacks, there may be thread safe issue because the getter of callbacks may in another queue (decoding queue or delegate queue)
// So we lock the operation here, and in `SDWebImageDownloaderOperation`, we use `@synchonzied (self)`, to ensure the thread safe between these two classes.
@synchronized (operation) {
downloadOperationCancelToken = [operation addHandlersForProgress:progressBlock completed:completedBlock];
}
// ....省略一堆代码
}
SD_UNLOCK(self.operationsLock);

// 虽然重复的URL只有一个SDWebImageDownloaderOperation。但是SDWebImageDownloadToken是每个URL都会有一个的,只是他们的SDWebImageDownloaderOperation是同一个。
SDWebImageDownloadToken *token = [[SDWebImageDownloadToken alloc] initWithDownloadOperation:operation];
token.url = url;
token.request = operation.request;
token.downloadOperationCancelToken = downloadOperationCancelToken;

return token;
}

对callbackBlocks的操作,也要保证线程安全,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// SDWebImageDownloaderOperation.m

@property (strong, nonatomic, nonnull) NSMutableArray<SDCallbacksDictionary *> *callbackBlocks;

- (nullable id)addHandlersForProgress:(nullable SDWebImageDownloaderProgressBlock)progressBlock
completed:(nullable SDWebImageDownloaderCompletedBlock)completedBlock {
SDCallbacksDictionary *callbacks = [NSMutableDictionary new];
if (progressBlock) callbacks[kProgressCallbackKey] = [progressBlock copy];
if (completedBlock) callbacks[kCompletedCallbackKey] = [completedBlock copy];
@synchronized (self) {
[self.callbackBlocks addObject:callbacks];
}
return callbacks;
}

2、它是如何保证请求管理的线程安全

说明:由于我们允许多个任务同时进行,也就造成了会有多个线程同时操作URLOperations属性。为了保证URLOperations操作(添加、删除)的线程安全性,我们添加了一个锁,且考虑到各种具备锁功能的性能问题,这里我们使用信号量semaphore。所以,我们控制线程安全的锁是使用信号量实现的operationsLock,且其初始值为1,用它来确保同一时间只有一个线程操作URLOperations属性

在SDWebImage版本5.8.1中的相关代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// SDWebImageDownloader.m

- (nonnull instancetype)init {
return [self initWithConfig:SDWebImageDownloaderConfig.defaultDownloaderConfig];
}

- (instancetype)initWithConfig:(SDWebImageDownloaderConfig *)config {
self = [super init];
if (self) {
// ....省略一堆代码
_downloadQueue = [NSOperationQueue new];
_downloadQueue.maxConcurrentOperationCount = _config.maxConcurrentDownloads;
_downloadQueue.name = @"com.hackemist.SDWebImageDownloader";
_URLOperations = [NSMutableDictionary new];
// ....省略一堆代码
_operationsLock = dispatch_semaphore_create(1);
// ....省略一堆代码
}
return self;
}

3、其它知识说明

3.1、SDWebImageDownloader 是什么?(异步的图片下载器)

SDWebImageDownloader是一个异步的图片下载器,它是一个单例类,主要负责图片的下载操作的管理。

图片的下载是放在一个NSOperationQueue操作队列中来完成的,默认情况下,队列最大并发数是6。

1
2
3
4
5
6
7
8
9
10
11
12
13
// SDWebImageDownloader.m
@property (strong, nonatomic, nonnull) NSOperationQueue *downloadQueue;


_downloadQueue = [NSOperationQueue new];
_downloadQueue.maxConcurrentOperationCount = _config.maxConcurrentDownloads;

// SDWebImageDownloaderConfig.h
/**
* The maximum number of concurrent downloads.
* Defaults to 6.
*/
@property (nonatomic, assign) NSInteger maxConcurrentDownloads;

如果需要的话,我们可以通过修改maxConcurrentDownloads属性来修改并发下载数。

二、其他版本的SDWebImage的代码解析

1、SDWebImageDownloader 是什么?(异步的图片下载器)

其声明及定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
//SDWebImageDownloader.m

@property (SDDispatchQueueSetterSementics, nonatomic) dispatch_queue_t barrierQueue;

- (id)init {
if ((self = [super init])) {
...
_barrierQueue = dispatch_queue_create("com.hackemist.SDWebImageDownloaderBarrierQueue", DISPATCH_QUEUE_CONCURRENT); // 并发队列
...
}
return self;
}

每有一个图片下载请求,SDWebImageDownloader图片下载管理器就会生成一个继承自NSOperation的下载操作SDWebImageDownloaderOperation,并添加到下载队列downloadQueue中。

[sself.downloadQueue addOperation:operation];

该队列允许修改最大并发数。

1、由于我们允许多个任务同时进行,也就造成了会有多个线程同时操作URLOperations属性。为了保证URLOperations操作(添加、删除)的线程安全性,我们添加了线程操作的信号量operationsLock,其初始值为1,来确保同一时间只有一个线程操作URLOperations属性,我们以添加操作为例,如下代码所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
LOCK(self.operationsLock);
SDWebImageDownloaderOperation *operation = [self.URLOperations objectForKey:url];
if (!operation) {
operation = createCallback();
__weak typeof(self) wself = self;
operation.completionBlock = ^{
__strong typeof(wself) sself = wself;
if (!sself) {
return;
}
LOCK(sself.operationsLock);
[sself.URLOperations removeObjectForKey:url];
UNLOCK(sself.operationsLock);
};
[self.URLOperations setObject:operation forKey:url];
}
UNLOCK(self.operationsLock);

2、同样由于为了避免同一个URL的图片被下载多次,所以我们并不会对每次操作一进来就进行创建请求任务,而是先通过之前缓存的下载任务URLOperations中通过URL寻找是否有该操作了,如果有则不创建,如果没有才创建。因此,这里就会有一个URL同时在被请求多次的情况下,生成/取到的是同一个SDWebImageDownloaderOperation。

三、其他

对于下载任务的执行,SDWebImage还允许我们设置是默认的先进先出还是后进先出。该功能的实现,其自然是通过队列操作的依赖来完成的(其中lastAddedOperation指的是上一次的操作)。源代码如下:

1
2
3
4
5
6
[sself.downloadQueue addOperation:operation];
if (sself.executionOrder == SDWebImageDownloaderLIFOExecutionOrder) {
// Emulate LIFO execution order by systematically adding new operations as last operation's dependency
[sself.lastAddedOperation addDependency:operation];
sself.lastAddedOperation = operation;
}

其他参考文章

END

图片库SDWebImage④其他

iOS - 主线程调度在应用中的小技巧

1
2
3
4
5
6
7
8
9
10
11
12
13
#pragma mark - 宏定义
#ifndef dispatch_queue_async_safe
#define dispatch_queue_async_safe(queue, block)\
if (dispatch_queue_get_label(DISPATCH_CURRENT_QUEUE_LABEL) == dispatch_queue_get_label(queue)) {\
block();\
} else {\
dispatch_async(queue, block);\
}
#endif

#ifndef dispatch_main_async_safe
#define dispatch_main_async_safe(block) dispatch_queue_async_safe(dispatch_get_main_queue(), block)
#endif

dispatch_queue_get_label:返回创建队列时为队列指定的标签。

END