基础数据基类

[toc]

基础数据基类

原则

1、属性

序号 方案 使用平台 推荐星数 其他
1 继承 iOS、Android、Flutter
2 组合 iOS、Android、Flutter
3 协议 iOS
4 抽象类 Flutter

1.1、继承

1.2、组合

1.3、协议(iOS)

在iOS开发中,除了使用Swift语言创建基类和组合属性的方式之外,还可以考虑使用协议(Protocol)来实现类似的效果。协议可以定义一组属性和方法的要求,然后其他类可以遵循该协议并提供相应的实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 商品价格
protocol GoodsPriceProtocol {
var marketPrice: Int? { get set } // 市场价格
var salePrice: Int? { get set } // 销售价格
}

// 商品品牌
protocol GoodsBrandProtocol {
var brandName: String? { get set } // 品牌名
var brandId: String? { get set } // 品牌id
}

// 商品基类
class BaseGoodsBean: GoodsPriceProtocol, GoodsBrandProtocol {
var marketPrice: Int?
var salePrice: Int?
var brandName: String?
var brandId: String?
}

1.4、抽象类

在Flutter中,没有与iOS中的协议(Protocol)直接对应的语言特性。然而,您可以使用抽象类(Abstract Class)来实现类似的效果。抽象类可以定义一组抽象方法和属性,然后其他类可以继承该抽象类并提供相应的实现。

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
// 商品价格
abstract class GoodsPriceModel {
int? marketPrice; // 市场价格
int? salePrice; // 销售价格
}

// 商品品牌
abstract class GoodsBrandModel {
String? brandName; // 品牌名称
String? brandId; // 品牌id
}

// 商品基类
class BaseGoodsBean implements GoodsPriceModel, GoodsBrandModel {
// 商品价格
@override
int? marketPrice;
@override
int? salePrice;

// 商品品牌
@override
String? brandName;
@override
String? brandId;
}

End

图片框架

[toc]

图片框架

思维脑图:请点击查看图片框架.xmind

流程图:请点击查看图片框架.graffle

一、图片优化

1、图片体验优化

1.1、图片占位图、异常图

1.2、图片加载动画

可适当在图片视图本身加入loading动画

2、图片内存优化

3、降低图片质量(缩略图/数据万象)

注意:图片所占内存的大小与图片的尺寸有关,而不是图片的文件大小

3.1、使用缩略图

让后台返回缩略图地址,而不是大图地址

3.2、图片数据万象及其优化\根据视图大小,获取保持比例的合适目标图片(完整图)

后台不是给缩略图地址的情况下,自己通过图片地址,配置对应的参数,来展示。

图片数据万象的优化,请继续查看下文 数据万象/根据视图大小,获取保持比例的合适目标图片(完整图))

4、图片缓存

3.1、直接图片视图加载

3.2、先加载图片数据,再赋值到图片视图上

3.3、去除多余的图片切换动画

5、提前解压缩

图片解压缩的过程其实就是将图片的二进制数据转换成像素数据的过程。

图片的解压缩是一个非常耗时的 CPU 操作,并且它默认是在主线程中执行的。那么当需要加载的图片比较多时,就会对我们应用的响应性造成严重的影响,尤其是在快速滑动的列表上,这个问题会表现得更加突出。这就是 UIImageView 的一个性能瓶颈。

二、数据万象/根据视图大小,获取保持比例的合适目标图片(完整图)

内存大小的计算:内存大小=宽度×高度×每像素字节数

对ARGB来说,每个像素有4个通道,每个通道1个字节,即其每个像素需要4个字节

对SRGB来说,其每个像素也是4个字节。(虽然其不包含Alpha通道,但存储时仍然可能按照每个像素4字节来处理,以保持数据对齐和优化内存访问。)

1、背景

在服务端,我们保存的是图片的原图。但实际在app使用过程中,我们常常只需要该图的缩略图。那么如何及合理的得到我们想要的缩略图呢?

2、改进前

2.1、方法

缩略图的获取,最基本的方法是使用数据万象提供的接口,通过为其添加尺寸参数,即可得到缩略图片。

添加后的缩略图地址形如:https://www.demo.com/1.jpg?width=44&height=44

2.2、存在的问题

使用上述方法,我们虽然得到了自适应每个image图片视图自身大小的缩略图。

但同时也引入了另一个问题:就是如果某张图需要出现在不同大小的image视图中,则我们会下载及缓存多份数据。这无形中增加了①流量的消耗、②手机存储空间的消耗;③同时也因为认为要下载新缩略图,而导致无法使用就近的缩略图。所以我们需要改进。

示例:

1
2
3
4
5
6
优化前:在 44*44 和 50*50 大小的视图区域上的图片地址分别如下:
https://www.demo.com/1.jpg?width=44&height=44
https://www.demo.com/1.jpg?width=50&height=50

优化后:两个区域统一为使用 64 * 64 大小的视图区域上的图片地址,从而如果避免获取到太多份不同大小的同图?
https://www.demo.com/1.jpg?width=64&height=64

3、改进后

3.1、改进方法

改进方式,在进行万象拼接前,我们通过提前新增缩略图梯度,将相近大小的缩略图归为使用同一张。

梯度的层数,可根据自己实际项目图片规范设计。

举例:以宽为 375pt 的手机屏幕为例。假设你图片规范是 [ 64pt 、188pt、375pt ]。

则当你的视图是 100pt 和 120pt 的视图都使用 188pt 的图片。

3.2、改进的好处

通过上述方法,我们能够达到①减少流量的消耗、②减少手机存储空间的消耗;③同时使得对于同一图片在相近大小的image图片视图的地方,由于计算出的缩略图处在同一梯度,而可以不用重新下载等待,而是直接使用缓存渲染,从而大大提升了用户体验。

4、流程

4.1、主要流程

获取梯度范围 -> 完成万象拼接

4.2、主要流程图

以下为”获取梯度范围”的流程

image-20230531212509071

图片的更多完整流程图,请查看 图片框架.graffle

三、图片高磁盘占用的排查与优化

文档:《高磁盘占用的排查与优化.md

四、图片展示

1、竖直滚动列表上的图片展示

2、图片大图浏览

image-20240725230554593

图片来源:图片框架.xmind

附1:图片优化的相关代码

1、图片占位图、异常图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
return CachedNetworkImage(
width: width,
height: height,
fit: fit,
imageUrl: imageUrl,
placeholder: placeholder,
errorWidget: (context, url, error) {
if (this.errorWidget != null) {
return this.errorWidget(context, url, error);
} else {
return Container();
}
},
);

2、图片加载动画

可适当在图片视图本身加入loading动画

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
return CachedNetworkImage(
width: width,
height: height,
fit: fit,
imageUrl: imageUrl,
placeholder: placeholder,
errorWidget: (context, url, error) {
if (this.errorWidget != null) {
return this.errorWidget(context, url, error);
} else {
return Container();
}
},
placeholderFadeInDuration: placeholderFadeInDuration,
fadeOutDuration: fadeOutDuration,
fadeInDuration: fadeInDuration,
progressIndicatorBuilder: (context, url, progress) {
if (this.progressIndicatorBuilder != null) {
return this.progressIndicatorBuilder(context, url, progress);
} else {
return Container(color: Color(0xFFF0F0F0));
}
},
);

3、图片缓存

3.1、直接图片视图加载

1
2
3
4
5
TolerantNetworkImage(
imageUrl: networkUrl,
width: 100,
height: 300,
)

3.2、先加载图片数据,再赋值到图片视图上

eg:首页频道图片切换

1
2
3
4
5
6
7
8
9
10
11
12
13
// 定义
ImageProvider imageProvider_network;

// 生成数据
imageProvider_network = CachedNetworkImageProvider(networkUrl);

// 使用数据
return Image(
image: imageProvider_network,
width: 100,
height: 300,
fit: BoxFit.cover,
);

3.3、去除多余的图片切换动画

eg:许个愿图片切换

1
2
3
4
5
6
7
8
9
10
11
12
return CachedNetworkImage(
width: width,
height: height,
fit: fit,
imageUrl: imageUrl,
placeholderFadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
fadeInDuration: Duration.zero,
progressIndicatorBuilder: (context, url, progress) {
return Container(color: Color(0xFFF0F0F0));
},
);

End

网络耗时及其优化

[toc]

第2节:网络耗时及其优化

一、网络耗时流程及优化方案结论

发起请求 -> 域名DNS解析 -> TCP三次握手 ( -> TLS握手 -> ) -> request -> response -> json解析 -> 业务

1、过程中的优化方案

序号 阶段 耗时根源 优化方案
1 发起请求 每个接口都要建立连接 前端配置想要的数据,后台通过一个接口返回
如商品详情页,请求[Base,Detai,Images]:商品基础信息+详情+图片
其他减少请求参数的body数据。
2 域名DNS解析 DNS解析过程不受控制,⽆法保证解析到最快的IP 方案1:IP直连 点击链接可跳转到下文介绍
HTTP(HTTPS)请求,底层基于TCP连接,需要有IP和端口信息。
⾃⼰做域名解析⼯作,通过HTTP请求后台去拿到域名对应的IP地址。
方案2:HTTPDNS
阿里云的DNS SDK:AlicloudPDNSAlicloudHTTPDNS
附:iOS14原生加密DNS方案
3 TCP三次握手 TCP:面向连接的协议
UDP:无连接协议
TCP 比 UDP 要慢
方案1:用HTTP实现长连接 点击链接可跳转到下文介绍
方案2:使用HTTP/3.0 采用了基于 UDP 的 QUIC 协议来替代传统的 TCP 协议
HTTP协议各版本使用情况
4 TLS握手 —— ——
5 request 服务端排查是否有耗时逻辑
6 response 数据体积大 删减冗余数据,开启gzip,数据分页、Protobuf、WebP等方式进行优化
Protobuf:见我的另一篇文章 protobuf的安装.md
分页:见我的另一篇文章 分页规范.md
7 json解析 解析耗时 采用解析效率更高的库,如 YYModel
8 业务 —— ——

上述优化方案的参考文档来源于:

2、其他优化方案

2.1、弱网优化:针对不同的网络,做不同超时,重试设置,以及取不同质量的图片数据。

2.2、缓存(必备:API缓存+可考虑加:Last-Modified/ETag)

见我的另一篇文章:

发布的内容,优先写入数据库,视频切片使用另一个请求异步获取。

二、网络耗时分布统计

1
2
3
4
5
6
7
8
9
class ResponseModel {
int statusCode;
String? message;
dynamic result;
bool? isCache;
bool? isSameToBefore; // 网络新数据是否和之前数据一样(重试/缓存)
final ResponseDateModel dateModel; // 这次结果的时间。
List<ResponseDateModel>? cacheDateModels; // 请求缓存接口过程的所有时间段(缓存数据获取时间、缓存数据执行时间、网络数据获取时间)。因为有时候得到一个结果是不仅经过一次,而是多次请求才得到的(如缓存),所以一个请求有多条时间线的时候。
}

1、ResponseDateModel dateModel; // 这次结果的时间

2、List? cacheDateModels; // 请求缓存接口过程的所有时间段(缓存数据获取时间、缓存数据执行时间、网络数据获取时间)。因为有时候得到一个结果是不仅经过一次,而是多次请求才得到的(如缓存),所以一个请求有多条时间线的时候。

1、网络请求各阶段的耗时(iOS)

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
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didFinishCollectingMetrics:(NSURLSessionTaskMetrics *)metrics API_AVAILABLE(macosx(10.12), ios(10.0), watchos(3.0), tvos(10.0)) 
{
// 首先检查是否是iOS 10.0或更高版本,如果不是,则退出方法
if (!@available(iOS 10.0, *)) {
return; // 退出方法
}

// 由于使用了@available注解,下面的代码默认只在iOS 10.0或更高版本上执行
for (NSURLSessionTaskTransactionMetrics *sMetric in metrics.transactionMetrics) {
NSInteger dom = timeDiff(sMetric.domainLookupStartDate, sMetric.domainLookupEndDate); // 域名解析
NSInteger sec = timeDiff(sMetric.secureConnectionStartDate, sMetric.secureConnectionEndDate);// TLS
NSInteger con = timeDiff(sMetric.connectStartDate, sMetric.connectEndDate); // 连接耗时(包括TLS)
NSInteger req = timeDiff(sMetric.requestStartDate, sMetric.requestEndDate); // 请求
NSInteger res = timeDiff(sMetric.responseStartDate, sMetric.responseEndDate); // 回调
NSInteger tot = timeDiff(sMetric.fetchStartDate, sMetric.responseEndDate); // 总耗时

NSString *locip = @"";
NSString *remip = @"";
if (@available(iOS 13.0, *)) {
locip = [NSString stringWithFormat:@"%@", sMetric.localAddress];
remip = [NSString stringWithFormat:@"%@", sMetric.remoteAddress];
}

NSLog(@"metric path:%@ 总耗时:%ldms, 域名解析:%ldms, 连接耗时:%ldms(包括TLS:%ldms), 请求:%ldms, 回调:%ldms l:%@ r:%@", sessionMetric.request.URL.lastPathComponent, (long)tot, (long)dom, (long)con, (long)sec, (long)req, (long)res, locip, remip);
}
}

// 辅助方法,计算两个NSDate之间的时间差,单位为毫秒
- (NSInteger)timeDifferenceInMilliseconds:(NSDate *)start to:(NSDate *)end {
return (NSInteger)((end.timeIntervalSince1970 - start.timeIntervalSince1970) * 1000);
}

Next:第3节:弱网优化空间探索

附1:HTTP协议各版本使用情况

类型 发布时间与现状
HTTP/1.1 在1997年被标准化,是目前互联网上广泛使用的HTTP协议版本
HTTP/2.0 于2015年发布,也是目前互联网上广泛使用的HTTP协议版本 不是对HTTP/1.1的重写,而是在性能方面的改进
HTTP/3.0 目前尚未成为主流,还在逐步推广和应用中 是基于Google的QUIC协议开发

附2:DNS

1、DNS介绍

1.1、DNS劫持/DNS污染前后

未劫持 VS 劫持

dns flow

1.2、DNS劫持模拟

1.3、如何检测 DNS 劫持

  • iOS 用户推荐 iNetTools
  • Android 用户推荐 LanDroid
  • 如何检测 DNS 劫持

    ping 一个不存在的 IP 地址却仍获得解析,则 DNS 很可能已被黑客入侵。

2、DNS的优化

方案1:IP直连

方案2:HTTPDNS

2.1、IP直连

iOS IP 直连原理剖析

为什么大家会选择直接使用 IP 来进行连接呢?它具有多方面的优势:

  • 防劫持,可以绕过运营商 LocalDNS 解析过程,避免域名劫持,提高网络访问成功率
  • 降低延迟,DNS 解析是一个相对耗时的工作,跳过这个过程可以降低一定的延迟
  • 精准调度,运营商解析返回的节点不一定是最优的,自己获取 IP 可以基于自己的策略来获取最精准的、最优的节点

对于获取 IP,我了解到的是两种方案,

一种是直接接入腾讯或者阿里的 HTTPDNS 服务,在发起请求的时候是通过 HTTPDNS 获取 IP,然后直接使用 IP 来进行业务访问;一种是内置 Server IP,可以在启动等阶段由服务端下发域名和 IP 的对应列表,客户端来进行缓存,发起网络请求的时候直接根据缓存 IP 来进行业务访问。

附3:用HTTP实现长连接

长连接:长连接的特点是一旦通过三次握手建立链接之后,该条链路就一直存在,而且该链路是一种双向的通行机制,适合于频繁的网络请求,避免 Http 每一次请求都会建立链接和关闭链接的操作,减少浪费,提高效率。

在首部字段中设置Connection:keep-alive 和Keep-Alive: timeout=60,表明连接建立之后,空闲时间超过60秒之后,就会失效。如果在空闲第58秒时,再次使用此连接,则连接仍然有效,使用完之后,重新计数,空闲60秒之后过期。

设置HTTP长连接,无过期时间:
在首部字段中只设置Connection:keep-alive,表明连接永久有效。

其他文章:tcp长短连接、http长短连接、心跳包、tcp KeepAlive保活机制

  1. TCP 是 HTTP 的默认传输层协议:HTTP 通常运行在 TCP 之上,因为 TCP 提供了可靠的、面向连接的服务,确保数据按顺序、完整无误地从源传送到目的地。这对于 Web 浏览器和服务器之间的通信非常重要,因为它们需要确保请求和响应的完整性。

End

其他参考文档:

项目 :iOS-Monitor-Platform 中的网络部分 https://github.com/aozhimin/iOS-Monitor-Platform?tab=readme-ov-file#network

网络框架

[toc]

思维脑图:请点击查看 网络框架.xmind

二、网络重试

三、网络缓存优化

四、网络耗时

五、网络使用过程中的优化

一、网络权限与初始化

系统网络权限弹窗机制:有代码执行到网络相关事项时候才会系统自动弹出。常见的场景:1监听网络状态变化;2进行任一网络请求。

验证代码:详见 CJNetworkDemo

network_system_window_permission1

图片来源于 《app启动与页面加载.graffle》 中的 【app启动(含网络与用户协议)(详细)】

问:如何确保网络初始化过程的安卓安全合规问题?

答:核心是将除网络库外的三方库统一放到同意用户协议后,避免其他三方库也可能调用获取敏感信息。至于网络库是否也放到同意协议后可用有两种方式。

处理方式一:同意协议前就可以初始化网络库,但是使用中涉及的敏感信息在未同意前使用自定义的特殊值(其他三方库先不初始化),且同意协议前仅网络库可初始化,其他三方库统一还是放到同意协议后,避免其他三方库也可能调用获取敏感信息。

优点:可以避免监听蜂窝的方式没弹出网络授权弹窗,导致网络请求始终调不通。(因为网络授权弹窗只有要么发起网络请求、要么监听网络请求的时候才可能弹出。但触发请求的次数明显比监听次数多)

缺点:代码里容易遗忘敏感信息的获取如安卓deviceId要先判断是否同意了用户协议,这是经常安卓审核合规不通过的常见原因。

使用注意:使用中一旦涉及敏感信息,一定要先判断是不是同意了用户协议,如果没同意则不要去获取。如安卓deviceId未同意用户协议前使用deviceId_not_agree_user_privacy

处理方式二:同意协议后才初始化三方库(含网络库)

优点:可以避免一些如deviceId的提前获取导致不符合安卓合规。

以下功能一般会牵涉到附中提到的 网络拦截器

二、网络重试

network_1retry_flow1

图片来源于 《网络框架的一生.graffle》 中的 【网络重试】

三、网络接口缓存流程

API缓存过程涉及附文中提到的 promise的resolve与next的流程

network_cache_flow1

图片来源于 《网络框架的一生.graffle》 中的 【网络缓存优化】

1、API缓存+可考虑加:Last-Modified/ETag

其他参考文档: ETag 、 Last-Modified 的介绍

使用 Last-Modified、ETag 其实就是在原本(见《001-UIKit-CQDemo-Flutter》 中的 【flutter_network_kit 库里判断responseModel.isSameToBefore部分】)缓存的基础上省去了这个判断。如果返回304,那就是代表没有新数据,使用beforeResponseModel。

1
2
3
4
5
if (beforeResponseModel != null) {
if (responseModel.isEqualToResponse(beforeResponseModel)) {
responseModel.isSameToBefore = true;
}
}

request.cachePolicy = NSURLRequestUseProtocolCachePolicy; 这行代码设置了请求的缓存策略,告诉NSURLSession根据HTTP协议的缓存相关头部(如Cache-ControlETagLast-Modified等)来决定是否使用缓存。

“If-Modified-Since”:服务器返回的 “Last-Modified”

“If-None-Match”:服务器返回的 “ETag”

1.1、在iOS上使用Last-Modified和ETag实现缓存

后端返回:

1
2
3
4
5
6
7
app.get('/your-endpoint', function(req, res) {
const lastModified = xxx;
const etag = 'unique-etag-value';
res.set('Last-Modified', lastModified.toUTCString());
res.set('ETag', etag);
res.send('Your resource content');
});

前端使用并回传:

1.1.1、自定义缓存逻辑 NSCache,写出使用Last-Modified和ETag来实现缓存的代码。
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
NSURL *url = [NSURL URLWithString:@"http://example.com/resource"];
NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url cachePolicy:NSURLRequestUseProtocolCachePolicy timeoutInterval:60];

// 检查并设置If-Modified-Since和If-None-Match头部
NSString *cachedLastModified = // 从本地获取Last-Modified值
NSString *cachedETag = // 从本地获取ETag值
if (cachedLastModified) {
[request setValue:cachedLastModified forHTTPHeaderField:@"If-Modified-Since"];
}
if (cachedETag) {
[request setValue:cachedETag forHTTPHeaderField:@"If-None-Match"];
}

NSString *storageKey = "LastModified_url和已去除timestamp等无关参数的参数 组成的key"
NSURLSession *session = [NSURLSession sharedSession];
NSURLSessionDataTask *dataTask = [session dataTaskWithRequest:request completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)response;
NSString *newLastModified = httpResponse.allHeaderFields[@"Last-Modified"];
NSString *newETag = httpResponse.allHeaderFields[@"ETag"];
if (httpResponse.statusCode == 200) {
// 资源有更新,保存新数据和新的Last-Modified、ETag
[self saveData:data LastModified:newLastModified ETag:newETag forKey:storageKey]
callback(data, isCacheData:false);
} else if (httpResponse.statusCode == 304) {
// 资源未更新,使用本地缓存数据(从本地再次加载缓存数据)
cacheData = [self loadCachedDataForKey:storageKey];
callback(data, isCacheData:true);
}

// 处理数据...
}];

[dataTask resume];
1.1.2、在不自定义缓存逻辑,且完全依赖 NSURLCache 来管理缓存的情况,写出使用Last-Modified和ETag来实现缓存的代码。

当使用 NSURLCache 并依赖系统的缓存机制时,通常不需要手动设置 Last-ModifiedETag 请求头字段。NSURLCache 会自动处理这些缓存相关的 HTTP 头字段,包括:

  • 在条件 GET 请求中自动添加 If-Modified-SinceIf-None-Match 请求头,使用之前响应中收到的 Last-ModifiedETag 值。
  • 当接收到来自服务器的响应时,根据响应中的 Last-ModifiedETag 头字段更新缓存条目。

如果你完全依赖 NSURLCache 来处理缓存逻辑,你的代码中不需要显式设置这些字段。NSURLCache 会在内部管理这些值,并根据 HTTP 协议标准执行适当的缓存行为。

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
// 1. 为 NSURLSessionConfiguration 配置一个 NSURLCache 实例(设置合适的内存和磁盘缓存容量)。
NSUInteger memoryCapacity = 4 * 1024 * 1024; // 内存缓存大小(如4MB)
NSUInteger diskCapacity = 20 * 1024 * 1024; // 磁盘缓存大小(如20MB)
NSURLCache *urlCache = [[NSURLCache alloc] initWithMemoryCapacity:memoryCapacity diskCapacity:diskCapacity diskPath:nil];
NSURLSessionConfiguration *sessionConfig = [NSURLSessionConfiguration defaultSessionConfiguration];
sessionConfig.URLCache = urlCache;

// 2. 使用配置好的 NSURLSessionConfiguration 创建 NSURLSession。
NSURLSession *session = [NSURLSession sessionWithConfiguration:sessionConfig];

// 3. 创建并发送请求。如果之前有缓存,并且缓存包含了 Last-Modified 或 ETag,则 NSURLCache 会根据这些信息自动处理条件请求。
NSURL *url = [NSURL URLWithString:@"http://example.com/resource"];
NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url];
NSURLSessionDataTask *dataTask = [session dataTaskWithRequest:request completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
if (error != nil) {
......
return;
}

NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)response;
switch (httpResponse.statusCode) {
case 200:
// 资源有更新,NSURLCache 会自动更新缓存
callback(data, isCacheData:false);
break;
case 304:
// 资源未修改,从 NSURLCache 获取缓存的数据
NSCachedURLResponse *cachedResponse = [[NSURLCache sharedURLCache] cachedResponseForRequest:request]; if (cachedResponse) {
NSData *cachedData = cachedResponse.data;
callback(cachedData, isCacheData:true); // 由于是缓存的数据,通常不需要更新UI,除非有特殊需求
}
break;
default:
// 处理其他HTTP状态码
break;
}
}];

[dataTask resume];

四、网络耗时及其优化

网络耗时及其优化.md

五、网络安全

请在安全篇章中查看

文档:《网络加密.md

流程图: 《网络加密.graffle

六、文件分片上传与分片下载

文件分片上传与分片下载.md

七、网络模拟

网络模拟文档请查看:网络ApiMock

Next:第2节:网络耗时及其优化

##

附1:网络拦截器

类型 时机 顺序:
请求拦截器(Request Interceptor) 请求开始前拦截 按照它们被添加的顺序,依次执行,修改即将发出的请求。
响应拦截器(Response Interceptor) 请求成功后拦截
错误拦截器(Error Interceptor) 请求失败后拦截

拦截器的执行顺序是根据它们各自被添加到请求实例的顺序决定的。(顺序只发生在同种类型间,不同类型间不影响。)

问1:如果第一个请求拦截器把参数修改了,第二个请求拦截器会怎么样?

答:如果第一个请求拦截器修改了请求参数,第二个请求拦截器接收到的将是修改后的请求参数。每个请求拦截器都会接收到前一个请求拦截器返回的RequestOptions对象,并且可以对其进行进一步的修改。

问2:如果是先添加响应拦截器,再添加请求拦截器会怎么样?

答:即使响应拦截器是先添加的,当实际发送请求时,请求拦截器会先执行,响应拦截器会在请求完成后执行。

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
Dio dio = Dio();

// 第一个请求拦截器
dio.interceptors.add(InterceptorsWrapper(
onRequest: (RequestOptions options) async {
// 假设我们修改了请求的queryParameters
options.queryParameters['firstInterceptor'] = 'modified';
print("第一个请求拦截器修改后的参数: ${options.queryParameters}");
return options;
},
));

// 第二个请求拦截器
dio.interceptors.add(InterceptorsWrapper(
onRequest: (RequestOptions options) async {
// 接收到第一个拦截器修改后的参数
// 我们可以再次修改它或者添加新的参数
options.queryParameters['secondInterceptor'] = 'added';
print("第二个请求拦截器接收到的参数: ${options.queryParameters}");
return options;
},
));

// 发送请求
dio.get('https://example.com')
.then((response) {
print("请求发送成功,并接收到响应: ${response.data}");
}).catchError((error) {
print("请求发送失败,并接收到错误: $error");
});

// 添加响应拦截器
dio.interceptors.add(InterceptorsWrapper(
onResponse: (Response response) {
// 响应拦截器逻辑
print("响应拦截器");
return response;
},
));

// 添加错误拦截器
dio.interceptors.add(InterceptorsWrapper(
onError: (DioError err, ErrorInterceptorHandler handler) {
// 错误拦截器逻辑
print("错误拦截器");
return handler.next(err); // 根据错误处理逻辑可能重新抛出错误或返回自定义响应
},
));

例如,Dio提供了三种类型的拦截器:请求拦截器(Request Interceptor)、响应拦截器(Response Interceptor)和错误拦截器(Error Interceptor)。每种类型的拦截器内部的执行顺序是固定的,但是不同类型拦截器之间的执行顺序则取决于它们被添加到Dio实例的顺序。

附2、promise的resolve与next的流程

使用 resolve 方法可以中断当前执行中的请求拦截器链,至于是否执行后续的响应拦截器取决于resolve 方法的第二个参数 callFollowingResponseInterceptor ,并直接返回一个 Response 对象作为请求的结果。

在 Dio 的 InterceptorsWrapper 中,使用 handler.resolve不一定会直接将响应返回给最初的请求调用者,而是将控制权交回给 Dio实例,由 Dio 来决定接下来的流程。resolve 方法实际上并不会退出拦截器链,而是根据 callFollowingResponseInterceptor 参数的值来决定是否执行后续的响应拦截器。但无论 callFollowingResponseInterceptor 设置为 true 还是 false调用者最终都能得到 Response 对象。这是因为响应拦截器的目的是处理和可能修改响应,但最终仍然需要将响应传递回调用者。不同之处在于,设置为 true 时会先执行响应拦截器,而设置为 false 时会直接返回响应给调用者,跳过响应拦截器。

network_promise_resolve

图片来源于 《网络框架的一生.graffle》 中的 【网络缓存优化】版面里的【promise的resolve与next】图层

End

其他参考文档:

异常与崩溃

[toc]

异常与崩溃

BUGLY崩溃问题汇总.md

一、异常捕获

通过三方捕获的方式,可以选择 Buly 友盟 等。详细的不展开,下面我们只讲底层自己进行异常捕获。

1、iOS异常捕获

iOS异常捕获

iOS上获取崩溃日志的N+1种方法

开发iOS应用,解决Crash问题始终是一个难题。Crash分为两种,

一种是由EXC_BAD_ACCESS引起的,原因是访问了不属于本进程的内存地址,有可能是访问已被释放的内存;

另一种是未被捕获的Objective-C异常(NSException),导致程序向自身发送了SIGABRT信号而崩溃。其实对于未捕获的Objective-C异常,我们是有办法将它记录下来的,如果日志记录得当,能够解决绝大部分崩溃的问题。

UncaughtExceptionHandler

2、Flutter异常捕获

系统已在performRebuild中异常捕获 FlutterError.onError = (FlutterErrorDetails details) { };

runZoned 中 handleUncaughtError 拦截未处理的异步错误

文档:《Flutter实战第二版:Flutter异常捕获:最终的错误上报代码

flutter_error_catch

点击查看源码

如果我想在应用程序退出时执行一些清理工作,我应该在 RunLoop 的哪个阶段进行操作?

1
2
3
4
// 程序被手动杀死。即将终止,可以在这里执行一些必要的清理工作
- (void)applicationWillTerminate:(UIApplication *)application {

}

applicationWillTerminate:这个方法并不总是可靠的,因为它的调用取决于多种因素,包括用户如何退出应用(例如,通过按 Home 键或通过系统设置)和系统状态。相比之下,RunLoop Observer 提供了一种更为可靠的方法来监听应用生命周期的特定阶段。通过在 RunLoop 的退出阶段添加一个 Observer,你可以确保在 RunLoop 即将退出时执行一些操作,这通常意味着应用即将进入后台或者终止。(还未亲自验证!!!)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 在 AppDelegate.m 文件中
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
// 创建 RunLoop Observer,并将 Observer 添加到主线程的 RunLoop
CFRunLoopObserverRef observer = CFRunLoopObserverCreateWithHandler(kCFAllocatorDefault, kCFRunLoopExit, TRUE, 0,&exitRunLoopCallback);
CFRunLoopAddObserver(CFRunLoopGetMain(), observer, kCFRunLoopCommonModes);
// 由于这是一个全局的 Observer,我们不需要在这里释放它。它会在应用退出时自动释放。

// 其他初始化代码...
return YES;
}

// Observer 的回调函数
void exitRunLoopCallback(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info) {
if (activity == kCFRunLoopExit) {
// 执行清理工作
NSLog(@"Performing cleanup work before the application exits.");

// 这里添加你的清理代码
}
}

二、如何追踪app

iOS 如何追踪app

三、起死回生/回光返照

1、iOS 使用Runloop实现起死回生/回光返照

核心:创建一个Runloop,将主线程的所有Runmode都拿过来跑,作为应用程序主Runloop的替代。

但是:这样固然可以实现我们想要做的事情,但是会带来一个问题:因为我们为了继续执行程序而没有将控制权返回给导致崩溃的调用函数,并且我们启动了自己的Runloop,所以永远不会返回到原始的Runloop中去了,这将意味着导致异常的线程使用的堆栈内存将永久泄漏。因此这种类型的方法应被视为调试工具或最后手段,所以,不要在Debug以外的环境使用它。本段摘自:线程保活提醒

视频:“Runloop起死回生/回光返照” 见 00:57:55–01:03:00 的视频 2021-08-21_Crash分析.wmv

文档:RunLoop总结:RunLoop的应用场景(五)阻止App崩溃一次

示例代码:https://github.com/Haley-Wong/RunLoopDemos

页面加载-1整体时长-2数据分析

[toc]

页面加载-1整体时长-2数据分析

五、数据分析

1、需求一期

1、原始数据整理

按版本整理数据,整理频率为常态下的下个版本提审时,统计上个版本。页面各时长数据:sheet_原始数据

page_load_data_ori1

2、原始数据分析1–异常数据

1、制定时长异常的参考标准。页面各时长数据:sheet_ref

image-20231102162146321

2、单独看每条记录,按异常程度,列出”超标”数据,并分析异常原因。

示例:

image-20231102161544919

3、合并看前后版本记录,比较版本差异数据

image-20231102162759893

2、需求二期

需求:页面时长变化趋势,不区分版本。绘制excel变化曲线图。

五、数据分析

前端APP/游戏,核心页面、核心接口的(首页4个tab,内容详情,商品详情,商城,订单,我的;主态农场,客态农场、下一家,排行榜,消息,收豆,抢羊,福利榜)。 TTI时间,游戏的加载时长,按照TP90/95统计数据,按天出数据。如何从大数据日志里抓数据,做成grafana监控大盘

核心页面数据分析,按周

核心页面控制在1s内,按90分位整理

End

页面加载时长-1需求规划

[toc]

页面加载时长-1需求规划

其他:

一、需求一期

1、新增原生页面的 异常Type、TTI

1、改造火山添加FP、FCP、FMP主要的时长数据。

①???页面时长支持从 跳转init 开始;

②新增 FMP_TYPE 类型,当类型异常时候,不进行时长上报,避免弱网等异常环境下对主数据的平均统计的干扰。

Type类型的说明如下:

序号 标识 描述 场景
1 Success 成功 用户进来放着等待到完成加载完,数据量少,但是是准确的时长
2 PointDown 用户点击 用户进来可能马上滑动或跳转到下一页,时间快
3 TimeOut 超时 弱网超时等,时间长

处理后,提供的数据表示例如下:

序号 页面描述 FP FCP FMP FMP_TYPE
1 作品首页 🆕 🆕 🆕 🆕
2 商城首页 🆕 🆕 🆕 🆕
3 发布页面 🆕 🆕 🆕 🆕
4 消息首页 🆕 🆕 🆕 🆕
5 我的首页 🆕 🆕 🆕 🆕

2、设计并新增TTI功能,及其数据类型。

序号 页面描述 FP FCP FMP TTI FMP_TYPE TTI_TYPE
1 作品首页 —- —- —- 🆕 —- 🆕
2 商城首页 —- —- —- 🆕 —- 🆕
3 发布页面 —- —- —- 🆕 —- 🆕
4 消息首页 —- —- —- 🆕 —- 🆕
5 我的首页 —- —- —- 🆕 —- 🆕

二、需求二期

1、原生视图–路由页内的标签页

路由页内的标签页,需要集成基类,统一在基类的init中开启记录,dispose的时候结束记录。

序号 页面描述 FP FCP FMP TTI FMP_TYPE TTI_TYPE
1 🆕作品–推荐 —- —- —- —- —- —-
2 🆕作品–同城 —- —- —- —- —- —-
3 🆕作品–热单 —- —- —- —- —- —-
4 🆕作品–关注 —- —- —- —- —- —-

2、非原生页面–H5

新增游戏等H5(非原生页面)的时长数据

描述 FP FCP FMP TTI FMP_TYPE TTI_TYPE
🆕H5-游戏 —- —- —- —- —- —-
🆕H5-用户协议 —- —- —- —- —- —-
🆕H5-隐私政策 —- —- —- —- —- —-
🆕H5-证件信息 —- —- —- —- —- —-
🆕H5-活动页 —- —- —- —- —- —-

三、需求三期

1、新增网络加载时长数据。

作用:当网络加载时长异常时,查看整个页面完整显示前的整条网络链路的耗时。

附:因为必须每个页面改动到业务逻辑代码,所以对改动顺序做以下优先级排序。

①优先以非主要页面切入,当系统稳定再进行后续核心页面的处理;

②在网络耗时功能测试稳定后,优先覆盖一级和二级的主要页面,继而再处理其他页面;

③处理其他页面;

描述 FP FCP FMP 网络加载时长
一级页面 —- —- —- 🆕
二级页面 —- —- —- 🆕

四、需求四期(单条记录表)

1、新增异常的的资源链接收集

新增异常的的资源链接收集,并上报到我们自己的后台。

作用:当网络加载时长异常时,通过上报的后台数据,查看并分析异常原因,

以《商品详情的FMP时长》异常为例:

1、筛选后台数据库,查看

①page == 商品详情页

②type != success

得到 商品详情的FMP时长 异常的所有数据记录

描述 FP FCP FMP 网络加载时长 失败的图片/视频链接 超时的图片/视频链接
H5-游戏 —- —- —- —- 🆕 🆕
H5-用户协议 —- —- —- —- 🆕 🆕
H5-其他 —- —- —- —- 🆕 🆕

五、需求五期(网络耗时)

本需求已独立为《页面加载-2网络时长-1需求规划》

End

页面加载网络时长-1需求规划

[toc]

页面加载网络时长-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
{
"page_duration": 8000,
"api": {
"total_duration": {
"start": 1000,
"end": 5000,
"duration": 4000,
},
"apis": [
{
"url": "api1",
"start": 1000,
"end": 5000,
"duration": 4000,
},
{
"url": "api2",
"start": 1000,
"end": 5000,
"duration": 4000,
}
]
}
}

步骤:

步骤 问题
1 一个页面加载过程必须进行的所有网络请求有哪些 8 梳理页面接口,建立并管理【页面(含页面类型)–到–页面加载过程必须进行的所有网络请求】的关联表
请求与页面加载的绑定:请求是哪个页面哪次进入发起的 基于梳理的页面接口,在请求时候
请求接口的开始和结束的记录及网络库对该数据的对外提供处理
请求接口的开始和结束的日志记录及展示处理
页面请求管理器(UnfinishPageApiManager):计算一个页面加载过程执行完所有必须的网络请求的耗时 4
页面绘制所需请求耗时信息的管理(AllPageTimeManager)
页面绘制各耗时信息总管理器(事件间等待,总完成的上报时机优化) 8

1、梳理页面接口,建立【页面(含页面类型)–到–页面加载过程必须进行的所有网络请求】的关联表管理器(静态、变更时需自己更新维护)

①梳理优先处理的核心页面

1
2
3
4
5
6
7
8
9
10
// allneedPage_ApiManager.dart
class AllNeedPageApiManager {
static Map<String, dynamic> allNeedPageApiMaps =
{
"ContentHomePage": [api1, api2],
"ContentDetailPage": [api1, api2],
"MallHomePage": [api1, api2],
"MallDetailPage": [api1, api2],
};
}

增加Type,区分一个页面不同业务使用。如订单列表中,我送出的和我收到的,同属于一个页面。

却通过type

2、网络库接口中,

  • 请求是哪个页面哪次进入发起的:

    为请求创建页面key:每次进入页面,创建页面唯一key,避免等下网络请求时候,不晓得是哪个页面哪个进入发起的。

  • 记录请求进行表

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
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
// unfinishPage_ApiManager.dart
class UnfinishPageApiManager {
// 所有页面的所有请求
static Map<String, dynamic> unfinishPageApiMaps = {};
// 示例
static Map<String, dynamic> unfinishPageApiMaps =
{
"ContentHomePage": [
{
apiUrl: api1,
apiStart: apiStart,
apiEnd: apiEnd,
},
{
apiUrl: api2,
apiStart: apiStart,
apiEnd: apiEnd,
}
],
"ContentDetailPage": [
{
apiUrl: api1,
apiStart: apiStart,
apiEnd: apiEnd,
},
{
apiUrl: api2,
apiStart: apiStart,
apiEnd: apiEnd,
}
],
};

// 页面请求开始
static void startApi({required String api, required String pageKey}) {
// 加锁🔐
// 获取指定页面已有的请求列表
List<Map<String, dynamic>> pageRequestingApiMaps = unfinishPageApiMaps[pageKey] ?? [];
// 添加新请求
Map<String, dynamic> newApiInfoMap = {
apiUrl: api,
apiStart: apiStart,
};
pageRequestingApiMaps.add(newApiInfoMap);
// 解锁🔐
}

// 页面请求结束
static void finishApi({required String api, required String pageKey}) {
// 加锁🔐
// 获取指定页面已有的请求列表,及其个数
List<Map<String, dynamic>> pageRequestingApiMaps = unfinishPageApiMaps[pageKey] ?? [];
// 找到此请求的开始信息,并补充上此请求的结束信息
for (Map<String, dynamic> item in pageRequestingApiMaps) {
item.addAll({"apiEnd": apiEnd});
}
int hasFinishCount = pageRequestingApiMaps.length;

// 获取指定页面原本应该有几个请求
List<Map<String, dynamic>> pageNeedApiMaps = PageApiManager.allNeedPageApiMaps[pageKey] ?? [];
int needCount = pageNeedApiMaps.length;

// 判断是否是最后一个请求,是的话上报本次页面的所有所需结束
if (hasFinishCount >= needCount) {
_notifyToPage(pageRequestingApiMaps);
}
// 解锁🔐
}

// 通知页面请求完成
static _notifyToPage(List<Map<String, dynamic>> pageRequestingApiMaps) {
int minStart;
int maxEnd;
for (Map<String, dynamic> item in pageRequestingApiMaps) {
int itemStart = item["apiStart"];
int itemEnd = item["apiEnd"];
if (minStart == null) {
minStart = itemStart;
} else {
minStart = min(minStart, itemStart);
}
if (maxEnd == null) {
maxEnd = itemEnd;
} else {
maxEnd = max(maxEnd, itemEnd);
}
}
if (minStart == null || maxEnd == null) {
debugPrint("发生错误了");
return;
}
int pageApiTotalDuration = maxEnd - minStart;
Map<String, dynamic> pageApiMap = {
"page_duration": pageApiTotalDuration,
"api": {
"total_duration": {
"start": 1000,
"end": 5000,
"duration": 4000,
},
"apis": pageRequestingApiMaps
}
}
eventBus.fire(PageFinishLoadRequest(pageApiMap: pageApiMap));
}
}

页面接收

1
2
3
4
5
6
7
8
9
10
class AllPageTimeManager {
pageRequestEvent = eventBus.on<PageFinishLoadRequest>().listen((event) {
Map<String, dynamic> pageApiMap = event.pageApiMap;
});


_uploadToSevice({required Map<String, dynamic> uploadPageInfo}) {

}
}

End

文件分片上传与分片下载

[toc]

前言

iOS m3u8本地缓存播放(控制下载并发、暂停恢复)

传统上传 VS 分片上传

传统上传方法的问题 分片上传的优点
大文件上传耗时长,容易导致超时。 将大文件拆分成较小的分片,更快更可靠地上传。
占用服务器和网络带宽资源,可能影响其他用户的访问速度。 监控并显示上传进度,提高用户体验。
如果上传中断,需要重新上传整个文件,效率低下。 充分利用浏览器的并发上传能力,减轻服务器负载。
难以显示和控制上传进度。 实现断点续传功能,避免重新上传已上传的分片。

传统文件下载 VS 文件分片下载

文件分片下载是一种通过将大文件拆分成较小的片段(分片)并同时下载它们来提高文件下载效率的技术。

问题/技术 传统文件下载 文件分片下载
长时间等待 用户可能需要等待很长时间才能开始使用大文件 只需下载第一个分片,客户端就可以开始使用文件
网络拥堵 如果网络带宽被大文件下载占用,其他用户可能会遇到下载速度慢的问题 可以使用多个并行请求来下载分片,充分利用带宽并提高整体下载速度
难以恢复下载 如果网络故障或用户中断,整个文件必须重新下载 如果下载被中断,只需重新下载未完成的分片,而不是整个文件
下载效率 下载速度较慢,特别是在网络不稳定或速度较慢的情况下 通过将大文件拆分成较小的片段并同时下载,提高文件下载效率
并行下载 不支持 支持,可以使用多个并行请求来下载分片
下载管理 整个文件作为一个整体进行下载 每个分片可以单独管理和下载,提供更好的灵活性

一、分片上传的实现方法

1、获取文件所有切片/分片:按指定的每个切片大小切(中断后的重新上传时,要过滤掉已上传完成的切片)。切片方法 [fileHandle readDataOfLength:chunkSize];

2、上传分片:提交时除了文件data,同时传入:总片数、当前是第几片。

3、上传所有分片后,有时候这里会额外调用服务器API告诉后台已完成全部上传。实际上后台自己也可以根据上传个数是否等于总数判断。服务端得到所有的数据片后合并数据。

其他注意点:

如果要上传的文件是录制的视频或者从系统相册中选择的视频,则该视频文件的路径必须是app的相对路径。缺点视频可被从相册中删除。

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
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
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
// FileChunk.m 文件切片的数据模型
@interface FileChunk : NSObject
@property (nonatomic, strong) ChunkFileModel *fileModel; // 切片来源于哪个文件(包含文件路径、文件大小,每个分片大小,分片总数)
@property (nonatomic, assign) NSUInteger index; // 数据块的位置(为了中断后的重新上传时候,过滤掉已上传完成的切片)
@property (nonatomic, assign) NSUInteger offset; // 数据块的起始位置(可能不同大小的文件,切片大小不一样,data的offset就不一样)
@property (nonatomic, strong) NSData *data; // 数据块内容
@property (nonatomic, strong) NSURLResponse *response; // 上传响应(会包含每片的上传状态)

// 获取文件单个切片:按指定的每个切片大小切
- (FileChunk *)initWithFileHandle(NSFileHandle *)fileHandle chunkSize:(NSUInteger)chunkSize chunkIndex:(NSUInteger)chunkIndex {
self = [super init];
if (self) {
chunk.data = [fileHandle readDataOfLength:chunkSize];// 执行后文件句柄的内部偏移量会根据读取的数据长度自动向前移动
chunk.offset = [fileHandle offsetInFile]; // 返回当前文件的偏移量,即文件句柄的当前位置
chunk.index = chunkIndex;
}
return self;
}

// 要上传的切片的要设置给 HTTPHeaderField:@"Content-Range"
- (NSString *)uploadContentRangeString {
return [NSString stringWithFormat:@"bytes %@-%@/total-size", @(chunk.offset), @(chunk.offset + chunk.data.length)];
}
@end



// FileChunkHelper.m
+ (void)addFinishSaveChunk:(FileChunk *)chunk forFilePath:(NSString *)filePath {
// 以key: chunk.belongfileMD5 保存已上传完成的切片索引 [0, 1, 2, chunk.index, ...]
}
+ (void)getFinishSaveChunkForFilePath:(NSString *)filePath {
// 以key: chunk.belongfileMD5 获取已上传完成的切片索引 [0, 1, 2, chunk.index, ...]
}

// 获取文件所有切片:按指定的每个切片大小切(中断后的重新上传时,要过滤掉已上传完成的切片)
+ (NSArray *)chunksFromFilePath:(NSString *)filePath
chunkSize:(NSUInteger)chunkSize
withoutChunkIndexs:(NSArray<NSUInteger>)chunkIndexs
{
NSMutableArray<FileChunk *> *chunks = [NSMutableArray array];

NSFileHandle *fileHandle = [NSFileHandle fileHandleForReadingAtPath:filePath];
[fileHandle seekFileOffset:0]; // 先定位到文件头部
int chunkIndex = 0;
FileChunk *chunk = [[FileChunk alloc] initWithFileHandle:fileHandle chunkSize:chunkSize chunkIndex:chunkIndex];
while (chunk.data) {
chunkIndex++;
if ([chunkIndexs contains chunk.index]) { // 为了中断后的重新上传时候,过滤掉已上传完成的切片
continue;
}
[chunks addObject:chunk];
}
[fileHandle closeFile];

return chunks;
}

// 单个分片的上传方法
+ (void)uploadChunk:(FileChunk *)chunk
uploadId:(NSString *)uploadId
completionHandler:(void (^)(BOOL success, NSError *error))completionHandler
{
NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:@"your-upload-chunk-api-endpoint"]];
request.HTTPMethod = @"PUT";
request.HTTPBody = chunk.data;
[request setValue:chunk.uploadContentRangeString forHTTPHeaderField:@"Content-Range"];

NSURLResponse *response = nil;
NSError *error = nil;
[NSURLConnection sendSynchronousRequest:request returningResponse:&response error:&error];
if (error) {
completionHandler(NO, error);
return;
}

chunk.response = response; // 如果上传成功,更新状态
[self addFinishSaveChunk:chunk forFilePath:chunk.filePath];
completionHandler(YES, nil);
}

+ (void)uploadChunks:(NSArray<FileChunk *> *)chunks
withUploadId:(NSString *)uploadId
completionHandler:(void (^)())completionHandler
{
dispatch_group_t group = dispatch_group_create();
NSMutableArray *uploadTasks = [NSMutableArray array];
for (FileChunk *chunk in chunks) {
dispatch_group_enter(group);
[FileChunkHelper uploadChunk:chunk uploadId:uploadId completionHandler:^(BOOL success,NSError *error) {
completionHandler(success, error);
dispatch_group_leave(group);
}];

[uploadTasks addObject:@{@"task": @"uploadChunk", @"chunk": chunk}];
}

dispatch_group_notify(group, dispatch_get_main_queue(), ^{
completionHandler();
});
}

2. 分片上传

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 请求文件是否上传过。(未上传过:返回用于上传的upload;正在上传中:返回upload;已上传过:返回url)
- (void)requestUploadInfoForFilePath:(NSString *)filePath
{
NSDictionary *parameters = @{
@"file_name": @"filename.txt", // 替换为实际文件名
@"file_size": @([FileChunkHelper getFileSize:filePath]), // 调用方法获取文件大小
@"part_size": @(partSize)
};
if (resultModel.uploadFinish) {
NSString *url = resultModel.url;
return;
}

NSString *uploadId = resultModel.uploadId;
//NSArray<NSInteger> finishChunkIndexs = resultModel.finishChunkIndexs; // 个人感觉不需要后台返回未完成的
// 1、获取文件所有切片:按指定的每个切片大小切(中断后的重新上传时,要过滤掉已上传完成的切片)
NSUInteger chunkSize = 1024 * 1024; // 1M
NSArray<NSUInteger> chunkIndexs = [FileChunkHelper getFinishSaveChunkForFilePath:filePath];
NSArray<FileChunk *> *chunks = [FileChunkHelper chunksFromFilePath:filePath chunkSize:chunkSize withoutChunkIndexs:chunkIndexs];
// 2、上传分片:提交时除了文件data,同时传入、总片数、当前是第几片
[FileChunksUpload uploadChunks:chunks withUploadId:uploadId completionHandler:^(void){
// 3、上传所有分片后,有时候这里会额外调用服务器API告诉后台已完成全部上传。实际上后台自己也可以根据上传个数是否等于总数判断
}];
}

二、文件分段下载

iOS 文件分段下载

iOS文件下载,断点续传,后台下载.

NSURLSessionDataTask 方式断点续传之下载

原理:
每次向服务器下载数据的时候,告诉服务器从整个文件数据流的某个还未下载的位置开始下载,然后服务器就返回从那个位置开始的数据流.通过设置请求头Range可以指定每次从服务器下载数据包的大小.

Range示例
bytes = 0-499 从0到499的头500个字节
bytes = 500-999 从500到999第二个500个字节
bytes = 1000- 从1000字节以后所有的字节
bytes = -500 最后500个字节
bytes = 0-499,500-999 同时指定几个范围

1
2
3
4
>  /// 下次请求的时候,设置请求头,每次从本地取数据把保存好的下载长度放进请求头Range中
> NSString *range = [NSString stringWithFormat:@"bytes=%lld-", self.currentLength];
> [request setValue:range forHTTPHeaderField:@"Range"];
>

数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
>  /// 初始化:通过NSOutputStream流向文件写出数据 ,append是否向尾部追加下载的数据
> self.stream = [[NSOutputStream alloc] initToFileAtPath:filePath append:YES];
>
> // 获取到服务器信息后,打开流,block回调允许接收下载数据
> [self.stream open];
> completionHandler(NSURLSessionResponseAllow);
>
> // 下载数据的回调:写入数据
> [self.stream write:[data bytes] maxLength:data.length];
>
> // 下载成功和失败都会来
> [self.stream close];
> self.stream = nil;
>

大文件视频上传:断点续传

End

必备知识架构-①语言

必备知识架构-①语言

[Toc]

目录

1
2
3
4
5
6
7
8
9
10
11
1、为什么说Objective-C是一门动态的语言
①、概念介绍
②、Objective-c的动态性
Objective-C 可以通过Runtime 这个运行时机制,在运行时动态的添加变量、方法、类等,所以说Objective-C 是一门动态的语言。
ObjC之所以说是面向Runtime的语言,最重要的特征是其消息传递机制。
Objective-C的动态性,主要体现在3个方面:动态类型、动态绑定、动态载入。
>
2、消息转发
3、为什么说 Objective-C 没有真正的私有方法和私有变量
4、消息结构
>
1
2
1、浅谈runtime运行时机制
>
1
2
3
4
5
6
7
1、#import、#include、@class](#import、#include、@class
2、const 与define使用的区别
3、==、 isEqualToString、isEqual区别
4、字符串的比较
5、isMemberOfClass 和 isKindOfClass 的联系和区别
6、nil, Nil,NULL 与 NSNull 的区别
>

一、为什么说Objective-C是一门动态的语言?

< 返回目录

问:我们说的OC是动态运行时语言是什么意思?

1、之所以叫做动态,是因为必须到运行时(run time)才会做一些事情。主要是将数据类型的确定由编译时,【推迟到了运行时】。简单来说, 运行时机制使我们直到运行时才去决定一个对象的类别,以及调用该类别对象指定方法。

所以说NSString *obj = [[NSData alloc] init] 在编译时是NSString类型,在运行时是NSData类型。

2、另外,OC还可以通过Runtime 这个运行时机制,在运行时【动态的添加】变量、方法、类等。

1、Objective-C是一门”动态”的语言,动态语言是什么意思?

OC中的对象,都是用指针表示
OC中方法的调用,是基于消息机制实现

①、”动态”概念介绍

(1)动态类型语言:动态类型语言是指在运行期间才去做数据类型检查的语言,Python和Ruby就是一种典型的动态类型语言,其他的各种脚本语言如VBScript也多少属于动态类型语言。(自己概括为动态语言指的是不需要在编译时确定所有的东西,在运行时还可以动态的添加变量、方法和类,而Objective-C 可以通过Runtime 这个运行时机制,在运行时动态的添加变量、方法、类等,所以说Objective-C 是一门动态的语言

(2)静态类型语言:静态类型语言与动态类型语言刚好相反,它的数据类型是在编译其间检查的,也就是说在写程序时要声明所有变量的数据类型,C/C++是静态类型语言的典型代表,其他的静态类型语言还有C#、JAVA等。

*obj
1
2
3
4
5
6
7
8
9
10


###### ②、Objective-c的动态性
Objective-C的动态性,让程序在运行时判断其该有的行为,而不是像c等静态语言在编译构建时就确定下来。**Objective-C的动态性主要体现在3个方面:动态类型、动态绑定、动态载入**。

> 以下内容来自:[OC是动态运行时语言是什么意思?什么是动态识别,动态绑定?](https://www.cnblogs.com/dxb123456/p/5525343.html)
>
* 1.动态类型:如id类型。
>
简单点的如id类型在编译器编译的时候是不能被识别的,要等到运行时(run time),即程序运行的时候才会根据语境来识别。

id obj = someInstance;
if ([obj isKindOfClass:someClass]) {
someClass classSpecifiedInstance = (someClass )obj;
}

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

* 2.动态绑定:
>在OC中,其实是没有函数的概念的,我们叫“消息机制”,所谓的函数调用就是给对象发送一条消息。
**OC可以先跳过编译,在运行时才决定要调用什么方法,需要传什么参数进去,这就是动态绑定**。要实现他就必须用SEL变量绑定一个方法。最终形成的这个SEL变量就代表一个方法的引用。(这里要注意一点:SEL并不是C里面的函数指针,虽然很像,但真心不是函数指针。SEL变量只是一个整数,他是该方法的ID,@selector()就是取类方法的编号。以前的函数调用,是根据函数名,也就是 字符串去查找函数体。但现在,我们是根据一个ID整数来查找方法,整数的查找字自然要比字符串的查找快得多!所以,动态绑定的特定不仅方便,而且效率更高。)

* 3.动态载入。

>根据需求加载所需要的资源,这点很容易理解,对于iOS开发来说,基本就是根据不同的机型做适配。最经典的例子就是在Retina设备上加载@2x的图片,而在老一些的普通屏设备上加载原图。




笔试题:对于语句`NSString *obj = [[NSData alloc] init];` obj在编译时和运行时分别是什么类型的对象?

> 答:编译时是NSString的类型;运行时是NSData类型的对象。



#### 2、怎么理解Objective-C"运行时"

基于Runtime的消息传递机制

**ObjC之所以说是面向Runtime的语言,最重要的特征是其消息传递机制。
什么是消息传递?消息传递不就和C语言的函数调用一个意思么?**

>在C语言中,我们调用函数时,必须先声明函数(或者自上而下),而实际上,声明函数就是获取函数地址,调用函数就是直接跳到地址执行,代码在被编译器解析、优化后,便是成为一堆汇编代码,然后连接各种库,完了生成可执行的代码(即是静态的)。
>
>在ObjC中,首先要搞清楚为什么不用Function Call 而用 Messaging 呢?一般调用函数(方法),讲的就是object对象,
>
>事情证明,在编译阶段,oc可以调用任何函数,即使这个函数并未实现,只要申明过就不会报错,而c语言在编译阶段或报错

######



# 1,给分类添加属性

OC分类不能添加属性,如果你在分类中添加了属性,编译器就会报警告。(记得导入 #import <objc/runtime.h>)

// UIButton+CJMoreProperty.h

![runtime添加属性](1语言/runtime_add1.png)

// UIButton+CJMoreProperty.m
![runtime添加属性](1语言/runtime_add2.png)



为分类添加属性,就用运用到OC的运行时来解决。

```csharp
// 设置关联
objc_setAssociatedObject(id _Nonnull object, const void * _Nonnull key,
id _Nullable value, objc_AssociationPolicy policy)
// 通过关联获取属性
objc_getAssociatedObject(id _Nonnull object, const void * _Nonnull key)
OBJC_AVAILABLE(10.6, 3.1, 9.0, 1.0, 2.0);

runtime添加属性

2、消息转发

ObjC之所以说是面向Runtime的语言,最重要的特征是其消息传递机制

1
2
3
4
5
6
7
8
Runtime的特性主要是消息(方法)传递,如果消息(方法)在对象中找不到,就进行转发。
1.1系统首先找到消息的接收对象,然后通过对象的isa找到它的类。
1.2在它的类中查找method_list,是否有selector方法。
1.3没有则查找父类的method_list。
1.4找到对应的method,执行它的IMP。
1.5转发IMP的return值。

如果找遍了父类,仍没找到方法,则报错。当然我们也可以通过消息转发,去其他类中寻找。

延展:消息转发机制

因为在运行期还可以继续向类中添加方法,所以编译器在编译时还无法确定类中是否有某个方法的实现。对于类无法处理一个消息就会触发消息转发机制。

消息转发步骤(如果前一套方案实现后一套方法就不会执行)

内容来自:轻松学习之 Objective-C消息转发

方案一:首先,系统会调用resolveInstanceMethod(当然,如果这个方法是一个类方法,就会调用resolveClassMethod)让你自己为这个方法增加实现

1
2
3
4
5
6
7
8
9
+ (BOOL)resolveInstanceMethod:(SEL)sel {
if (sel == @selector(run)) {
class_addMethod(self, sel, (IMP)run, @"v@:");
return YES;
}
return [super resolveInstanceMethod:sel];
}

+ (BOOL)resolveClassMethod:(SEL)sel

方案二:如果不去对方案一的resolveInstanceMethod做任何处理,直接调用父类方法。可以看到,系统已经来到了forwardingTargetForSelector方法,这个方法返回你需要转发消息的对象。

1
2
3
4
//返回你需要转发消息的对象
- (id)forwardingTargetForSelector:(SEL)aSelector {
return [[Car alloc] init];
}

方案三:如果如果我们也不实现forwardingTargetForSelector,系统就会调用方案三的两个方法methodSignatureForSelector和forwardInvocation。其中methodSignatureForSelector用来生成方法签名,这个签名就是给forwardInvocation中的参数NSInvocation调用的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//生成方法签名
- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector {
NSString *sel = NSStringFromSelector(selector);
//判断你要转发的SEL
if ([sel isEqualToString:@"run"]) {
//为你的转发方法手动生成签名
return [NSMethodSignature signatureWithObjCType:@"v@:"];
}
return [super methodSignatureForSelector:selector];
}

// 附:关于生成签名的类型"v@:"解释一下。每一个方法会默认隐藏两个参数,self、_cmd,self代表方法调用者,_cmd代表这个方法的SEL,签名类型就是用来描述这个方法的返回值、参数的,v代表返回值为void,@表示self,:表示_cmd。


- (void)forwardInvocation:(NSInvocation *)anInvocation {
SEL selector = [anInvocation selector];
//新建需要转发消息的对象
Car *car = [[Car alloc] init];
if ([car respondsToSelector:selector]) {
//唤醒这个方法
[invocation invokeWithTarget:car];
}
}

消息转发分为两大阶段:

“动态方法解析”:先征询接收者,所属的类,能否动态添加方法,来处理这个消息,若可以则结束,如不能则继续往下走

“完整的消息转发机制”:
请接收者看看有没其他对象能处理这条消息,若有,就把这个消息转发给那个对象然后结束
运行时系统会把与消息有关细节全部封装到NSInvocation 对象中,再给对象最后一次机会,令其设法解决当前还未处理的这条消息

其他文章:NSInvocation简单使用

在 iOS中可以直接调用 某个对象的消息 方式有2中
一种是performSelector:withObject:
再一种就是NSInvocation
第一种方式比较简单,能完成简单的调用。但是对于>2个的参数或者有返回值的处理,那就需要做些额外工作才能搞定。那么在这种情况下,我们就可以使用NSInvocation来进行这些相对复杂的操作。
NSInvocation可以处理参数、返回值。会java的人都知道凡是操作,其实NSInvocation就相当于反射操作。

1
2
3
4
5
6
7
8
9
10
11
//如果此消息有参数需要传入,那么就需要按照如下方法进行参数设置,需要注意的是,atIndex的下标必须从2开始。原因为:0 1 两个参数已经被target 和selector占用
if (strcmp(retType, @encode(BOOL)) == 0) {
NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:methodSig];
[invocation setArgument:&params atIndex:2];
[invocation setSelector:action];
[invocation setTarget:target];
[invocation invoke];
BOOL result = 0;
[invocation getReturnValue:&result];
return @(result);
}

3、为什么说 Objective-C 没有真正的私有方法和私有变量

以下内容摘自:为什么说 Objective-C 没有真正的私有方法和私有变量

私有的定义:私有是指只能够在本类内部使用或访问,但是不能在类的外部被访问。

在 Objective-C 中,对象调用方法是以发送消息的形式实现的。所有方法的调用最终都会转化为发送消息的形式,原型如下:

id objc_msgSend(id self, SEL op, …)

Objective-C 为什么能够实现访问「私有方法」呢?其实这跟 Objective-C 语言的动态特性有密切的关系,对象在运行的时候才会去查找方法。Objective-C 对象有一个 isa 指针指向其父类,在向该实例发送消息的时候,若它自己不能识别回到父类中去查找该消息。

综上,OC中其实并无真正意义上的的私有方法和私有属性。但是在实际使用中,我们应遵守规则,不能调用的方法,不调用。

4、消息结构

Objective-C 使用的是 “消息结构” 并非 “函数调用”。(使用消息结构的的语言,其运行时所应执行的代码由运行期决定;而使用函数调用的语言,则由编译器决定)

以下内容摘自:OC消息机制和动态运行时

1
2
3
4
5
6
7
8
9
10
11
12
13
struct objc_class
{
Class isa;
Class super_class; //父类
const char* name; //类名
long version; //版本信息
long info; //类信息
long instance_size; //实例大小
struct objc_ivar_list *ivars; //实例参数链表
struct objc_method_list *methodLists; //方法链表
struct objc_cache *cache; //方法缓存
struct objc_protocol_list *protocols; //协议链表
}

二、Runtime

< 返回目录

Runtime 4种用法

1
2
3
4
1)替换系统方法,[数组越界苹果就会直接crash](http://blog.csdn.net/u012103785/article/details/50817876)
2)字典转model,
3)归档, 
4)万能控制器跳转

你会遇到的runtime面试题(详)

OBJC_ASSOCIATION_ASSIGN造成的崩溃

objc_setAssociatedObject 关联对象的学习
CJKeyboardAvoidingTableView增加cjKeyboardAvoidingOffset属性的时候,使用OBJC_ASSOCIATION_ASSIGN而造成崩溃,参考FDTemplateLayoutCell源码,改成使用OBJC_ASSOCIATION_RETAIN,问题解决。原因是

详情查询:objc_setAssociatedObject 关联对象的学习

1、浅谈runtime运行时机制

浅谈runtime运行时机制02-runtime简单使用

OC是运行时语言,只有在程序运行时,才会去确定对象的类型,并调用类与对象相应的方法。利用runtime机制让我们可以在程序运行时动态修改类、对象中的所有属性、方法。

iOS运行时初探-使用运行时机制向Category中添加属性

在一般情况下,我们是不能向Category中添加属性的,只能添加方法,但有些情况向,我们确实需要向Category中添加属性。而且很多系统的API也有一些在Category添加属性的情况,例如我们属性的UITableView的section和row属性,就是定义在一个名为NSIndexPath的分类里的,如下:
使用运行时机制向Category中添加属性
那这到底是怎么实现的呢?

+load +initialize的区别

\1. load方法只会调用一次

\2. +(void)load方法:程序一运行就会把所有的类加载进内存,调用这个方法

(表叙:只要程序一运行,就会调用类的load方法,目的:把这个类加载进内存)

\3. load是只要类所在文件被引用就会被调用,而initialize是在类或者其子类的第一个方法被调用前调用,所以没有被引用进项目,就不会调用load方法,但即使类文件只被引用进来,而没有使用,那么initialize也不会被调用

  • +load方法的调用是在main() 函数之前。load 方法内部一般用来实现 Method Swizzle(面向切面AOP变成)

    调用main() 函数之前的什么时候?答:+load方法会再这个类或者其分类被添加到运行时的时候调用。

    附:所有类的 load 方法都会被调用,先调用父类、再调用子类,多个分类会按照Compile Sources 顺序加载。

  • initialize一般在main()函数之后。Initialize方法一般用来初始化全局变量或者静态变量。

    问是在main() 函数之后的什么时候?答:在第一次主动使用当前类的时候。

    附:initialize一般在main()函数之后的情况是什么?A类的 load 方法中调用 B 类的类方法,导致在调用A的Load 方法之前,会先调用一下B类的initialize 方法,但是B类的load 方法还是按照 Compile Source 顺序进行加载

    附2:Initialize 方法会被覆盖,子类父类分类中只会执行一个

  • load和initialize都不能主动调用。

  • initialize不是init,initialize在程序运行过程中,它会在你程序中每个类调用仅一次initialize。这个调用的时间发生在你的类接收到消息之前,但是在它的父类接收到initialize之后。
    init则是你手动初始化几次就调用几次,和普通方法一样

三、KVC与KVO

1、KVO

KVO的概述与使用 内容如下

一、概述

KVO,即键值观察者(key-value-observing),它提供一种观察者的机制,当指定的对象的属性被修改后,则对象就会接受到通知。简单的说就是每次指定的被观察的对象的属性被修改后,KVO就会自动通知相应的观察者了。

二、KVO原理/本质

Aspect 的实现很想 KVO的本质:详见 Aspect 与 消息转发

KVO是使用isa-swizzling的技术实现的:当某个类的对象第一次被观察时,系统在运行时会通过Runtime动态创建一个该类的派生类,并将这个对象的isa指针指向这个派生类,派生类的isa指针指向原来的类,这样修改对象a的属性即对象a调用set方法就会变成是调用的是NSKVONotifying_A这个类的set方法。

随后在该派生类中重写了该对象的setter方法,并且在setter方法中实现了通知的机制。

派生类重写了class方法,以“欺骗”外部调用者他就是原先那个类。系统将这个类的isa指针指向新的派生类,因此该对象也就是新的派生类的对象了。因而该对象调用setter就会调用重写的setter,从而激活键值通知机制。此外派生类还重写了delloc方法来释放资源。

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
> #pragma mark - 注册观察者 - 函数式编程
> - (void)cjl_addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath handleBlock:(LGKVOBlock)block {
> ......
> //2、动态生成子类
> Class newClass = [self createChildClassWithKeyPath:keyPath];
> object_setClass(self, newClass); // 将self的isa指向由原有类改为指向创建出来的派生类/中间类
>
> // 给子类NSKVONotifying_A添加方法一个名为setterSel的方法,这个方法的实现是cjl_setter。
> SEL setterSel = NSSelectorFromString(setterForGetter(keyPath));
> Method method = class_getInstanceMethod([self class], setterSel);
> const char *type = method_getTypeEncoding(method);
> class_addMethod(newClass, setterSel, (IMP)cjl_setter, type);
> }
>
> static void cjl_setter(id self, SEL _cmd, id newValue){
> ......
> }
>
>
>
> #pragma mark - 动态生成子类
> - (Class)createChildClass {
> // 动态创建子类
> NSString *oldClassName = NSStringFromClass([self class]); //获取原本的类名
> NSString *newClassName = [NSString stringWithFormat:@"NSKVONotifying_%@", oldClassName];//拼接新的类名
> Class newClass = objc_allocateClassPair([self class], newClassName.UTF8String, 0);
> objc_registerClassPair(newClass);
> // 给子类NSKVONotifying_A添加方法一个名为class的方法,这个方法的实现是cjl_class。
> SEL classSel = @selector(class);
> Method classMethod = class_getInstanceMethod([self class], classSel);
> const char *classType = method_getTypeEncoding(classMethod);
> class_addMethod(newClass, classSel, (IMP)cjl_class, classType);
>
> return newClass;
> }
>
> Class cjl_class(id self, SEL _cmd){
> return class_getSuperclass(object_getClass(self));//通过[self class]获取会造成死循环
> }
>

以上实现参考自:iOS-底层原理 23:KVO 底层原理 自定义KVOiOS 探究 OC对象、isa指针及KVO实现原理

其他参考:KVO原理及自定义KVO

答. 当一个对象使用了KVO监听,iOS系统会修改这个对象的isa指针,改为指向一个全新的通过Runtime动态创建的子类,子类拥有自己的set方法实现,set方法实现内部会顺序调用willChangeValueForKey方法、原来的setter方法实现、didChangeValueForKey方法,而didChangeValueForKey方法内部又会调用监听器的observeValueForKeyPath:ofObject:change:context:监听方法。

如何手动触发KVO 答. 被监听的属性的值被修改时,就会自动触发KVO。如果想要手动触发KVO,则需要我们自己调用willChangeValueForKey和didChangeValueForKey方法即可在不改变属性值的情况下手动触发KVO,并且这两个方法缺一不可。

isa指针

isa是结构体指针,只要是OC对象都有isa

  1. 实例对象的isa指向它的类对象。
  2. 类对象的isa指向元类对象(元类是类的类,它存储了类的方法和属性信息。为什么要设计元类,为了复用消息传递功能)。

当调用对象方法时,实例对象内存并没有包含对象方法,而是通过它内部的isa指针找到它的类对象,从而在类对象中找到对象方法的实现进行调用;

当调用类方法时,类对象并没有类方法的信息,而是通过类对象的isa找到元类对象,最后找到类方法的实现进行调用

  1. instance调用对象方法的轨迹:isa找到class,方法不存在,就通过superclass找父类
  2. class调用类方法的轨迹:isa找meta-class,方法不存在,就通过superclass找父类

isa&superclass

三、使用方法

系统框架已经支持KVO,所以程序员在使用的时候非常简单。

  1. 注册,指定被观察者的属性,
  2. 实现回调方法
  3. 移除观察

四,实例:

假设一个场景,股票的价格显示在当前屏幕上,当股票价格更改的时候,实时显示更新其价格。

2、KVC

KVC 就是键值编码(key-value-coding)

KVC 的主要作用:

(1)通过键值路径为对象的属性赋值。主要是可以为私有的属性赋值。

(2)通过键值路径获取属性的值。主要是可以通过key获得私有属性的值。

1
2
3
4
5
6
7
8
9
10
11
12
// valueForKeyPath:(多级访问,不仅可以对当前对象属性进行赋值,也可以对“深层”对象的属性进行取值)
Person *person1 = [[Person alloc] init];
person1.name = @"李四";
person1.car.name = @"BMW";
NSString *carName = [person valueForKeyPath:@"car.name"];


// 当集合中元素类型一致,可以通过KVC的getter方法valueForKeyPath:来获取所有集合元素中的属性,生成一个新的元素属性集合。(这里的集合一般为NSArray,NSSet,不包括NSDictionary)
NSArray *personArray = [NSArray arrayWithObjects:person1, nil];

NSArray *personNameArray = [personArray valueForKeyPath:@"name"];
NSArray *carNameArray = [personArray valueForKeyPath:@"car.name"];

其他参考资料:

四、Objective-C和C++

< 返回目录

1、Objective-C与C++之间的差异

最大的区别就是Objective C的成员函数调用是基于动态绑定的,类本身并没有限制对象可以有什么函数。相对于C++类会规定好成员有什么函数。这使得Objective C的灵活性很大,但是执行效率也很低。

2、Object-c的类可以多重继承么?

答: Object-c的类不可以多重继承;

面向对象(OOP)

面向对象的三个特性:封装、继承和多态(没有继承就没有多态)。

所谓封装,也就是把客观事物封装成抽象的类;

所谓多态就是指一个类实例的相同方法在不同情形有不同表现形式。多态机制使具有不同内部结构的对象可以共享相同的外部接口。

没有继承就没有多态。代码的体现:父类类型的指针指向子类对象。

没使用多态前:

1
2
3
4
5
6
7
8
9
10
11
12
Person.m
- (void)printWithBlack:(Printer *)printer {
[printer print];
}
- (void)printWithColor:(Printer *)printer {
[printer print];
}



[person printWithBlack:blackPrint]; //调用黑白打印机
[person printWithColor:colorPrint]; //调用彩色打印机

使用多态后:

1
2
3
4
5
6
7
8
9
10
11
12
Person.m
- (void)print:(Printer *)printer {
[printer print];
}


// 使用时候:
Printer *p1 = [[ColorPrinter alloc] init];
Printer *p2 = [[BlackPrinter alloc] init];

[person doPrint:p1];
[person doPrint:p2];

常见笔试/面试题

< 返回目录

1、#import、#include、@class

可参考:#import、#include、@class、#import<>和#import””的区别

1、#import介绍:
In the C language, the #include pre-compile directive always causes a file’s contents to be inserted into the source. at that point, Objective-C has the equivalent #import directive except each file is included only once per compilation unit, obviating(去除) the need for include guards.

即在C语言中,#include预编译指令经常引起一个文件的被重复引用。基于这一点,OC有一个等价的预编译指令#import来防止文件重复导入。所以OC给出来的新的预处理指令import是防止重复导入,避免程序员去书写头文件卫士,降低程序员的负担。

2、#import和#include的异同点:

①#include是C语言中的,需要条件编译语句控制重复包含问题。

②在Objective-C中,#import是OC中对#include的改进版本,#import使得我们不需要写条件编译语句就可以确保引用的文件只会被包含一次,不会陷入递归版包含的问题。即import与#include比较的优点:会自动防止重复导入,使得不会引起交叉编译

#import不会引起交叉编译的问题。因为在Objective-C中会存在C/C++和Object-C混编的问题,如果用#include引入头文件,会导致交叉编译。

举例:三个文件①文件A.h、②文件B.h、③文件C.h。其中文件C.h需要引入A.h、B.h,而文件B.h也需要引入文件A.h,这样在C.h中就重复引用了A.h两次,使用#import可以进行优化

3、#import和#include的相同点:

①都可以用在OC程序中起到导入文件的作用

②同样的 包含系统文件都是<>,是包本地文件都用””
例如:系统文件#import<Foundation/Foundation.h>, #include<stdio.h>
​ 本地文件#import”test.h”, #include”test.h”

到底该如何选择使用呢
一般来说,你包含的是C语言中的文件就用#include,你用的是OC中的文件就用#import

4、#import与@class二者的区别

#import会链入该头文件的全部信息,包括实体变量和方法等;而@class只是告诉编译器,其后面声明的名称是类的名称,至于这些类是如何定义的,暂时不用考虑。在头文件中, 一般只需要知道被引用的类的名称就可以了。不需要知道其内部的实体变量和方法,所以在头文件中一般使用@class来声明这个名称是类的名称。 而在实现类里面,因为会用到这个引用类的内部的实体变量和方法,所以需要使用#import来包含这个被引用类的头文件。

附:@class能解决循环包含的问题:当两个类文件有循环依赖关系 (A引用B,B引用A)时,需要用@class

5、import<> 和 import””
①<> : 引用系统文件,它用于对系统自带的头文件的引用,编译器会在系统文件目录下去查找该文件.

②””: 用户自定义的文件用双引号引用,编译器首先会在用户目录下查找,然后到安装目录中查

4、字符串的比较

字符串compare;
字符串compare的比较是一个字符串一个字符串比较的,所以才会出现1814比860小的错误情况。

5、isMemberOfClass 和 isKindOfClass 的联系和区别

  • 联系:两者都能检测一个对象是不是某个类的成员

  • 区别:isKindOfClass不仅能确定一个对象是不是某个类(即不算父类)的成员,也能确定一个对象是否是派生自该类的类的成员,而isMemberOfClass只能做到前者.

a.为什么基本类型和C数据类型的修饰用assign?

因为基本数据类型不是对象,不需要引用计数,所以使用assign(附:在内存中创建和使用后,在一个方法体结束后就被删除。)。

iOS NS/CG/CF前缀代表什么意思

NS/CG/CF前缀代表什么意思。

回答:NS是前缀,代表它是来自Cocoa Foundation框架,这个框架是OC的基础库

UI—UIKit框架,UI库
CF—Core Foundation框架
NS—Cocoa Foundation框架,OC的基础库。
CG—CoreGraphics.frameworks框架,用于渲染的库。
CA—CoreAnimation.frameworks框架

swift @EnvironmentObject 等于全局变量?

在SwiftUI中,@EnvironmentObject 并不是全局变量,但它确实提供了一种在多个视图之间共享和访问数据的方式,这在某种程度上类似于全局变量的作用。不过,它们之间还是有一些关键的区别:

  1. 所有权和生命周期
    • 全局变量:在程序的整个生命周期内都存在,并且全局可访问。
    • @EnvironmentObject:需要一个环境对象提供者(通常是视图树中的父视图)来创建和管理其实例。它只在提供者的子视图中可访问,并且其生命周期受限于提供者。
  2. 访问控制
    • 全局变量:任何地方都可以访问,这可能导致难以追踪变量的使用情况和修改点,从而增加代码的复杂性和出错概率。
    • @EnvironmentObject:只能在环境对象被提供的子视图中访问,这有助于限制数据的访问范围,使得数据流和状态变化更容易理解和跟踪。
  3. 类型安全和可组合性
    • 全局变量:通常不是类型安全的,因为它们可以在任何地方被任何类型的代码访问和修改。
    • @EnvironmentObject:是类型安全的,因为它要求使用特定的对象类型,并且可以通过SwiftUI的视图组合模型来管理。
  4. 响应性
    • 全局变量:不是响应式的,意味着它们的状态变化不会触发UI的更新。
    • @EnvironmentObject:是响应式的,基于ObservableObject协议。当环境对象的状态发生变化时,依赖于这个环境对象的视图会自动更新。
  5. 依赖注入
    • 全局变量:不直接支持依赖注入。
    • @EnvironmentObject:支持依赖注入,可以通过环境对象提供者来注入所需的依赖。

总结来说,@EnvironmentObject 提供了一种更加模块化和响应式的方式来共享和管理状态,而不是使用全局变量。它有助于构建更清晰、更易于维护的代码结构,尤其是在复杂的应用程序中。

END

< 返回目录

1
2