阅读视图

发现新文章,点击刷新页面。

[转载] 【WWDC21 10158】VideoToolbox 视频编码基础及其低延时新特性

原文地址

本文基于 Session 10158 梳理。随着直播互动性增强,对直播延时的要求也越来越高,高延时会严重影响用户体验。本 Session 介绍的 VideoToolbox 低延时编码从编码角度来降低延时,给我们提供了降低延时的新思路。

VideoToolbox 编解码基础

VideoToolbox 简介

VideoToolbox 是苹果提供的一个直接访问硬编解码器的底层框架,可以用来编码、解码和像素格式转换。这些功能都以 session 的形式提供。如果你的 App 中不需要直接访问硬编解码器,那不需要使用 VideoToolbox,可以使用其他框架例如 AVFoundation。

上图为 Apple 视频编解码框架图,我们主要关注 AVFoundation 和 VideoToolbox。

框架 编码 解码 iOS 编解码类型
AVFoundation 直接编码为文件 解码后直接渲染播放 硬编解码
VideoToolbox 编码为 CMBlockBuffer 解码为 CVPixelBuffer,需要自己处理渲染 硬编解码

VideoToolbox 常用数据结构

  • CVPixelBufferPool:CVPixelBuffer 缓冲池,用于管理一组可重用的 CVPixelBuffer

  • CVPixelBuffer:未编码的原始数据

  • CMBlockBuffer:编码后的数据

  • CMFormatDescription:编解码格式信息,包括以下信息:

    • Width / Height
    • Format Type—(kCMPixelFormat_32BGRA, kCMVideoCodecType_H264,……)
    • Extensions—(Pixel Aspect Ratio, Color Space,……)
  • CMTime:时间信息,表示时间点或者段,value / timeSacle

  • CMSampleBuffer:编码、解码数据容器,详细结构见下图:

H.264 码流格式

H.264 流是由一系列的 NAL Units(简称 NALU)组成,如下图所示:

NALU 可能包括:

  • 视频帧或者视频帧的一个分片(slice)
  • H.264 参数集:SPS(序列参数集)和 PPS(图像参数集)

Annex-B 和 AVCC

根据 NALU 的分隔符不同,可以把 H.264 流分为 Annex-B 格式和 AVCC 格式。

格式 特点 常用于格式 常用场景 备注
Annex-B 使用 3~4 字节 start code 0x00000001/0x000001 分割,SPS/PPS 为普通 NALU ts 流媒体 每个 I 帧前都需要添加 SPS/PPS 信息
AVCC 使用 4 字节大端序 NALU 长度进行分割,在 extradata 中封装 SPS/PPS mp4/flv/mkv 本地文件 只要在文件头添加 SPS/PPS 信息

特别需要注意的是:iOS 平台的 VideoToolbox 硬编解码接口只支持 AVCC 的 H.264 数据,而 Android 的 MediaCodec 硬编解码接口只支持 Annex-B 格式的 H.264 数据。
因此对于 iOS 而言,编码后得到 AVCC 格式数据,在推流前需要转为流媒体对应的 Annex-B 格式。拉流解码则刚好相反。

NALU 结构

NALU header

如下图所示,header 占一个字节,分为 3 个部分:

forbidden_zero_bit
禁止位,初始为 0,当网络发现 NALU 有错误时可设置该比特为 1,以便接收方纠错或丢掉该单元。
nal_ref_idc
nal 重要性指示,标志该 NALU 的重要性,值越大,越重要,解码器在解码处理不过来的时候,可以丢掉重要性为 0 的 NALU。
nal_unit_type
NALU 类型,NALU 第 5 个字节(前四个字节为 start code 0x00000001) & 00011111(十六进制为 0x1F),即 int type = (frame[4] & 0x1F)。
常用类型:

  • 5:IDR(I 帧)
  • 7:SPS
  • 8:PPS

编码流程

重要的参数、属性:

  • CMVideoCodecType:编码类型,kCMVideoCodecType_H264 对应 H.264
  • kVTCompressionPropertyKey_ProfileLevel:指定编码比特流的配置文件和级别。直播一般使用 baseline,可减少由于 b 帧带来的延时
  • kVTCompressionPropertyKey_RealTime:是否实时编码
  • kVTCompressionPropertyKey_AverageBitRate:平均码率
  • kVTCompressionPropertyKey_AllowTemporalCompression:是否开启帧间压缩
  • kVTCompressionPropertyKey_MaxKeyFrameInterval:设置 GOP 大小,每隔 X 帧有一个关键帧
  • kVTCompressionPropertyKey_MaxKeyFrameIntervalDuration:关键帧之间的 duration,每 Y 秒有一个关键帧。这两个属性可以同时设置,满足其中一个即可。

解码流程

重要的参数、属性

  • CMVideoFormatDescription:输入视频格式信息,可通过 SPS/PPS 创建。
  • destinationPixBufferAttrs:解码输出属性,是 CFDictionary,有如下 key:
    • kCVPixelBufferPixelFormatTypeKey:像素格式
    • kCVPixelBufferWidthKey/kCVPixelBufferHeightKey:宽高
    • kCVPixelBufferOpenGLCompatibilityKey:它允许在 OpenGL 的上下文中直接绘制解码后的图像,而不是从总线和 CPU 之间复制数据。这有时候被称为零拷贝通道,因为在绘制过程中没有解码的图像被拷贝。
    • kCVPixelBufferIOSurfacePropertiesKey:使用 IOSurface 来创建 CVPixelBuffer 时,需要为此 key 赋值。赋值为一个空字典表示使用默认的 IOSurface 选项。
  • VTDecompressionOutputCallback:解码回调,每次完成解码或者丢帧时都会调用,而且回调会阻塞解码器直到回调 return,因此要避免耗时操作。此外,解码器会以 dts 顺序返回视频帧,B 帧的 pts 和 dts 不一致,需要开发者进行视频帧重排序。

常见问题

VideoToolbox session 后台失效

App 切到后台时,iOS 的 VideoToolbox session 会失效,切回前台后原 session 也不能继续使用,需重新创建 VideoToolBox 实例。

VideoToolbox 低延时编码

概述

低延时编码对于许多视频应用非常重要,尤其是实时视频通信应用。本 session 将介绍 VideoToolbox 中一种新的编码模式,以实现低延时编码,这种新模式的目标是针对实时视频应用优化现有的编码器 pipeline。注意,此模式支持的视频编解码器类型为 H.264,将在 iOS 15.0+macOS 12.0+ 上引入此功能。

实时视频应用的目标

  1. 延时:我们需要最大限度地减少通信中的端到端延时,提升交流体验。
  2. 兼容性:我们需要通过让视频应用能够与更多设备进行通信来增强兼容性。
  3. 编码效率:当多人视频时,编码器 pipeline 应该是高效的。
  4. 视频质量:视频应用需要以最佳视觉质量呈现视频。
  5. 容错能力:我们需要一种可靠的机制来从网络丢失引入的错误中恢复通信。

低延时视频编码将在以上这些方面进行优化,对应的功能为:

  1. VideoToolbox 低延时编码基础功能
  2. 新的 profile
  3. 时间可扩展性(temporal scalability)
  4. 最大帧量化参数(max frame quantization parameter)
  5. 长期参考(long-term reference)

低延时编码基础功能

低延时编码是什么?

下图为 Apple 平台上视频编码 pipeline 的简图。

VideoToolbox 将 CVImagebuffer 作为输入,它要求视频编码器执行压缩算法,例如 H.264 以减少原始数据的大小。输出的压缩数据封装在 CMSampleBuffer 中,可以通过网络传输进行视频通信。从上图中我们可以注意到,端到端延时可能受两个因素影响:编码时间网络传输时间

为了最大限度地减少编码时间,低延时编码模式去除帧重新排序,遵循一进一出模式。其实相当于把 kVTCompressionPropertyKey_AllowFrameReordering 属性设置为 false:禁用 B 帧,去除因编码 B 帧带来的延时。此外,该模式下的码率控制器对网络变化的适应速度也更快,因此也最大限度地减少了网络拥塞造成的延时。通过这两个优化,我们已经可以看到与默认模式相比有明显的性能提升。对于 720p@30 的视频,低延时编码可以减少高达 100 毫秒的延时。这种节省对于视频会议至关重要。

低延时编码怎么使用?

只需要在 VTCompressionSessionCreate 的入参 encoderSpecification 中设置 EnableLowLatencyRateControl,其他配置和平常的编码流程一样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
let encoderSpecification: [NSString: NSObject] = [
kVTVideoEncoderSpecification_EnableLowLatencyRateControl: kCFBooleanTrue
]

var compressionSession: VTCompressionSession?
VTCompressionSessionCreate(
allocator: kCFAllocatorDefault,
width: width,
height: height,
codecType: kCMVideoCodecType_H264,
encoderSpecification: encoderSpecification as CFDictionary?,
imageBufferAttributes: nil,
compressedDataAllocator: nil,
outputCallback: videoEncodeCallback,
refcon: nil,
compressionSessionOut: &compressionSession
)

低延时编码其他功能

  1. 新的 profile:新增 2 个 profile 来增强兼容性
  2. 时间可扩展性(temporal scalability):在视频会议中非常有用,通过降低帧率来满足低带宽网络环境
  3. 最大帧量化参数(max frame quantization parameter ):可以对图像质量进行细粒度控制
  4. 长期参考(long-term reference):提高容错能力

新的 profile

实际应用时需要其他端的解码器也支持

Profile 定义了一组解码器能够支持的编码算法。为了与接收方通信,编码后的码流应符合解码器支持的特定 profile。在 Video Toolbox 中,我们支持一系列 profile,例如 baseline profile、main profile 和 high profile。该系列添加了两个新 profile:constrained baseline profile(CBP) 和 constrained high profile(CHP)。CBP 主要用于低码率应用,而 CHP 具有更先进的算法以获得更好的压缩比

1
2
3
4
5
6
7
8
9
10
11
VTSessionSetProperty(
compressionSession,
key: kVTCompressionPropertyKey_ProfileLevel,
value: kVTProfileLevel_H264_ConstrainedBaseline_AutoLevel // value CBP
)

VTSessionSetProperty(
compressionSession,
key: kVTCompressionPropertyKey_ProfileLevel,
value: kVTProfileLevel_H264_ConstrainedHigh_AutoLevel // value CHP
)

时间可扩展性(temporal scalability)

使用该特性可以提高多方视频通话的效率。
下图为一个简单的三方视频会议场景:在此模型中,接收方 A 的带宽较低为 600 kbps,而接收方 B 的带宽较高为 1,000 kbps。通常,发送方需要对编码输出两路码流,以满足每个接收方的下行带宽(ps:实际现在一般是主播推一路流,cdn 进行转码),这可能不是最佳的方案。

当使用该特性时,编码会更高效,模型如下图:发送方只需要编码输出一路码流,然后根据接收方进行分层。

实现原理

下图为一组视频帧,其中每一帧都使用前一帧作为参考帧。可以将一半的帧放入另一层,更改参考帧,以便只有原始层中的帧用于参考帧。原始层称为 base layer,新构建的层称为 enhancement layer。enhancement layer 可以作为 base layer 的补充,以提高帧率。对于接收方 A,我们可以发送 base layer 帧,因为基础层本身已经是可解码的。更重要的是,由于 base layer 仅包含一半的帧,因此传输的码率将很低。接收方 B 可以享受更流畅的视频,因为它有足够的带宽来接全部视频帧。


收益

  1. 通过降低帧率来满足低带宽网络环境:base layer 可以用 60% 的码率来达到 50% 的帧率。

  2. 增强容错能力:enhancement layer 中的帧不用于预测,因此对这些帧没有依赖性。这意味着如果在网络传输过程中丢失了一个或多个增强层帧,其他帧不会受到影响。

API

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
// 创建并开启低延时编码器
VTCompressionSessionCreate(...)

// 设置 base layer 比例
VTSessionSetProperty(
compressionSession,
key: kVTCompressionPropertyKey_BaseLayerFrameRateFraction,
value: Float(0.5) as CFTypeRef
)

// 设置 base layer 码率占比
VTSessionSetProperty(
compressionSession,
key: kVTCompressionPropertyKey_AverageBitRate, // 目标码率
value: Int(bitrate) as CFTypeRef
)
// 默认是0.6,取值范围:[0.6, 0.8]
VTSessionSetProperty(
compressionSession,
key: kVTCompressionPropertyKey_BaseLayerBitRateFraction,
value: Float(baseLayerBitrateFrac) as CFTypeRef
)


// 编码回调中查看某一帧是 base layer 还是 enhancement layer
let attachArray = CMSampleBufferGetSampleAttachmentsArray(
sampleBuffer,
createIfNecessary: true
)
let isBaseLayer = CFDictionaryGetValue(
CFArrayGetValueAtIndex(attachArray, 0),
kCMSampleAttachmentKey_IsDependedOnByOthers
)
```

### 最大帧量化参数(max frame quantization parameter, max frame QP)

Frame QP 用于调节图像质量和码率可以使用小 QP 来生成高质量的图像在这种情况下,图像数据会很大另一方面,可以使用大 QP 来生成低质量但数据量小的图像
在低延时模式下,编码器使用图像复杂度输入帧率视频运动等因素调整 QP,以在当前码率约束下产生最佳视频质量`所以苹果鼓励依靠编码器的默认行为来调整帧 QP`
但是在某些客户端对视频质量有特定要求的情况下,可以设置编码器使用的最大 QP,编码器将始终选择小于此限制的 QP,因此客户端可以对图像质量进行细粒度控制
值得一提的是,即使指定了最大 QP,常规码率控制仍然有效如果编码器达到最大 QP 上限但码率已达到设置的目标码率,它将开始丢弃帧以保持目标码率
使用此功能的一个例子是通过较差的网络传输录屏内容可以通过牺牲帧率来发送清晰的屏幕图像

#### API

```swift
// 创建并开启低延时编码器
VTCompressionSessionCreate(...)
// maxFrameQP [1,51]
VTSessionSetProperty(
compressionSession,
key: kVTCompressionPropertyKey_MaxAllowedFrameQP,
value: maxFrameQP as CFTypeRef
)

长期参考(long-term reference,LTR)

LTR 可用于错误恢复。下图显示了 pipeline 中的编码器、发送方和接收方。假设视频通信的网络状况不佳。由于传输错误,可能会发生帧丢失。当接收方检测到帧丢失时,它可以请求刷新。如果编码器收到请求,通常它会编码一个关键帧(I 帧)以用于刷新。但关键帧通常相当大。大的关键帧需要更长的时间才能到达接收方。由于网络条件已经很差,关键帧可能会加剧网络拥塞问题。

实现原理

那么,我们可以使用 P 帧而不是关键帧进行刷新吗?答案是肯定的,如果我们有帧 ack 机制。原理如下图所示:

  1. 首先,我们需要确定需要 ack 的帧。我们称这些帧为长期参考帧或 LTR 帧。这是编码器的决定,编码后的 LTR 帧附加信息中有 AcknowledgementToken,用来标记该 LRT 帧。
  2. 当发送方传输 LTR 帧时,还需要接收方 ack。如果接收方成功接收到 LTR 帧,则需要返回 AcknowledgementToken。
  3. 一旦发送方收到 ack,并在编码时把收到的 AcknowledgementTokens 发送给编码器,编码器就知道对方收到了哪些 LTR 帧,就可以用这些 LTR 帧来生成 P 帧。由于一次可以收到多个 ack,需要使用一个数组来存储这些 AcknowledgementTokens。
  4. 当编码器收到接收方刷新请求时,由于编码器有一堆已确认的 LTR 帧,它可以从中选取一帧作为参考帧进行编码得到一个 P 帧,以这种方式编码的帧称为 LTR-P。与关键帧相比,LTR-P 大小通常要小得多,因此更容易传输。如果没有确认的 LTR 可用,编码器将生成一个关键帧。


LTR API

帧 ack 需要由应用层处理。它可以通过 RTP 协议中的 RPSI 消息等机制来完成。这里只关注编码器和发送方在这个过程中是如何通信的。

API

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
// 创建并开启低延时编码器
VTCompressionSessionCreate(...)
// 开启 LTR 功能
VTSessionSetProperty(
compressionSession,
key: kVTCompressionPropertyKey_EnableLTR,
value: kCFBooleanTrue
)

// 设置编码属性,传递 tokens
let frameProperties: [NSString: AnyObject] = [
// 强制生成 LTR-P 帧
kVTEncodeFrameOptionKey_ForceLTRRefresh: kCFBooleanTrue,
// 编码时传递 tokens 给编码器
kVTEncodeFrameOptionKey_AcknowledgedLTRTokens: [token1, token2,...., tokenN] as NSObject
]

var flags: VTEncodeInfoFlags = []
VTCompressionSessionEncodeFrame(
compressionSession,
imageBuffer: imageBuffer,
presentationTimeStamp: presentationTimeStamp,
duration: duration,
frameProperties: frameProperties as CFDictionary?,
sourceFrameRefcon: nil,
infoFlagsOut: &flags
)


// 编码回调中,在 LTR 帧附加信息中获取 ack token
let attachArray = CMSampleBufferGetSampleAttachmentsArray(
sampleBuffer,
createIfNecessary: true
)
let token = CFDictionaryGetValue(
CFArrayGetValueAtIndex(attachArray, 0),
kVTSampleAttachmentKey_RequireLTRAcknowledgementToken
)

业界降低延时的方法


优化播放器缓冲区配置

下图为直播全链路延时分布图,这里重点关注解码渲染时播放器的数据缓冲区,为了抗卡顿确保流畅播放,播放器必须要缓冲媒体数据。缓冲区引入的延时在全链路比重占据较大,因此优化缓冲区配置是一个降低延时的常用手段。

选择合适的协议

目前国内外主流的音视频直播协议多种多样,国内使用比较多的是 rtmp 推流 flv 拉流方案,国外使用比较多的有 hls/dash 等。可以根据不同的直播场景和不同的延时要求选择合适的协议,具体如下:

协议 传输方式 封装格式 延时 数据分段
http-flv http flv 3~7sec 连续流
rtmp tcp flv-tag 2~4sec 连续流
hls http 文件 ts 8~15sec 切片文件
dash(cmaf) http 文件 mp4/webm 3~10sec 切片文件
quic udp flv-tag 3~10sec 连续流
rts udp rtp 0.6~1.2sec 连续流
srt udp ts < 1s 连续流
  • RTMP(Real Time Messaging Protocol)是基于 TCP 的,由 Adobe 公司为 Flash 播放器和服务器之间音频、视频传输开发的开放协议。
  • HLS(HTTP Live Streaming)是基于 HTTP 应用层的以 Apple 公司主导开发的音视频传输协议。
  • HTTP FLV 则是将 RTMP 封装在 HTTP 协议之上的,可以更好的穿透防火墙等。
  • CMAF (通用媒体应用格式 Common Media Application Format) 是利用的 ISOBMFF,fMP4 容器,同 HLS 类似,将视频流分段进行传输。
  • QUIC(Quick UDP Internet Connection)是谷歌公司制定的一种基于 UDP 协议的低时延传输协议;它将很多可靠性的验证策略从系统层转移到应用层来做,更适合现代流媒体传输的拥塞控制策略。iOS 15+/macOS 12+ 的 Network 框架已经支持了 HTTP3/QUIC,并可以将自有协议转换至 QUIC 之上。更多细节请参考 Accelerate networking with HTTP/3 and QUIC
  • RTS (Real Time Streaming via WebRTC)是基于谷歌 webRTC 的一种实时音视频传输技术,底层构建于 UDP 之上,浏览器通用兼容标准。
  • SRT(Secure Reliable Transport)是一种能够在复杂网络环境下实时、准确地传输数据流的网络传输技术,它在传输层使用 UDP 协议,具备 UDP 速度快、开销低的传输特性,支持点对点传输,无需中间进行服务器中转。SRT 的更多信息请查阅 Secure Reliable Transport (SRT) Protocol

总结

本 session 介绍了 VideoToolbox 中新引入的低延时编码模式,从编码角度来降低直播延时。但是该模式只支持 H.264 编码,而且实际应用时需要考虑到多端兼容和应用层协议的支持,更多是苹果在 H.264 低延时编码方面的一些探索。

视频编解码

视频编码

为什么要编码

假设一个 1 小时的未压缩的电影(1920 * 1080),像素数据格式为 RGB(3 Byte),按照每秒 25 帧来计算:

1
3600 * 25 * 1920 * 1080 * 3 = 521.42GB

500 多 G 的电影显然太大了,下载耗时也占地方,在线看也很慢,毕竟我们平时看的一般也就几个 G 而已。所以我们要对视频进行 压缩,而 编码 就是 压缩 的过程。

视频编码的作用: 将视频像素数据(RGB,YUV 等)压缩成对应标准的视频码流,从而降低视频的数据量。

视频编码首先要转换下色彩空间, 从 RGB 转化为 YUV:

YUV 色彩空间转换

YUV 和 RGB 是不同的色彩空间, RGB 是采用三种基本色为基础进行叠加, 从而产生不同的颜色; 而 YUV 是通过亮度和色差来描述颜色的颜色空间, Y表示明亮度(Luma), 也就是灰阶值, U 和 V 表示的则是色度(Chrominance)和浓度(Chroma), 作用是描述影像色彩及饱和度, 用于指定像素的颜色。因此对于黑白显示设备, 只需要去除色度分量, 只显示亮度分量即可。YUV细分的话有Y’UV / YUV / YCbCr / YPbPr等类型, 其中YCbCr主要用于数字信号。在流媒体领域中, YUV 其实就是指 YCbCr。

YCbCr的Y与YUV中的Y含义一致, Cb和Cr与UV同样都指色彩, Cb指蓝色色度, Cr指红色色度

RGB和YUV的换算公式如下:

1
2
3
Y =  0.299 R' + 0.587 G' + 0.114 B'
U = -0.147 R' - 0.289 G' + 0.436 B' = 0.492(B' - Y)
V = 0.615 R' - 0.289 G' + 0.436 B' = 0.877(R' - Y)

对于不同明亮度, UV 的表现如下:

YUV采样方式(即YCbCr)

YUV 码流的存储格式其实与其采样的方式密切相关, 主流的采样方式有三种, YUV4:4:4, YUV4:2:2, YUV4:2:0,
4:1:1含义就是:在2x2的单元中, 本应分别有4个Y, 4个U, 4个V值, 用12个字节进行存储。经过4:1:1采样处理后, 每个单元中的值分别有4个Y、1个U、1个V, 只要用6个字节就可以存储了

  • YUV 4:4:4采样, 每1个Y对应一组UV分量, 消耗 3*4 = 12byte
  • YUV 4:2:2采样, 每2个Y共用一组UV分量, 邻近4个像素点的亮度分量是被完整保留, Cb/Cr分量分别进行了下采样只保留了1/2, 而人眼看起来几乎不会察觉到变化. Y: 4byte, U: 2byte, V: 2byte, 共 8byte, 就这样简单粗暴的操作我们就节省出了1/3的存储空间
  • YUV 4:2:0采样, 每4个Y共用一组UV分量, Y: 4byte, U: 1byte, V: 1bit, 共 6byte, 对应地可以节省出 1/2 的空间.

视频编码方案

视频编码标准历史

  • MPEG:国际标准化组织及国际电工委员会ISO/IEC旗下的动态图像专家组MPEG(Moving Picture Experts Group)
  • VCEG:国际电联电信标准化部门ITU-T旗下的视频编码专家组VCEG(Video Coding Experts Group)

AVC/H.264:集大成者一统江湖

2001 年,ISO 的MPEG 组织认识到H.26L 潜在的优势,随后ISO 与ITU 开始组建包括来自ISO/IEC MPEG与ITU-T VCEG 的联合视频组(JVT),JVT 的主要任务就是将H.26L 草案发展为一个国际性标准。于是,在ISO/IEC中该标准命名为AVC(Advanced Video Coding),作为MPEG-4 标准的第10 个选项;在ITU-T 中正式命名为H.264标准。该标准在2003 年3 月正式获得批准。

他的特点是高压缩比、高图像质量、良好的网络适应性,在较低带宽上提供高质量的图像传输。 其采用了更灵活的宏块划分方法、数量更多的参考帧、更先进的帧内预测和压缩比更高的数据压缩算法。

HEVC/H.265/MPEG-H Part 2:视频编码王位继任者

H.264很强大,但是它在超清时代有点不够用了。随着视频分辨率的跨越式提升,H.264表现出了疲态,它在应对4K视频时已经没有办法提供很好的压缩比了。很明显,人们需要新的编码来继承它的位置,而它的直接继承者——HEVC,在经过多年研究之后,终于在2013年被通过了。

HEVC,全称高效视频编码(High Efficiency Video Coding),同样的,它也是由MPEG和ITU-T联合制定的国际标准编码。被包含在MPEG-H规范中,是为第二部分(Part 2),在ITU-T那儿,它是H.26x家族的新成员,为H.265。

HEVC主要是针对高清及超清分辨率视频而开发的,相比起前代AVC,它在低码率时拥有更好的画质表现,同时在面对高分辨率视频时,也能提供超高的压缩比,帮助4K视频塞入蓝光光盘。

相较于AVC,HEVC在高分辨率下的编码效率又有非常大的提升,举个实例,同样一段4K视频,使用H.264编码的大小可能会比使用HEVC大出个一倍。这种巨大的进步幅度也使得Blu-ray直接用它作为标准编码,推出了UHD BD,而它在单帧图像压缩上面的改进也让它拥有胜过JPEG的能力,于是我们看到在移动端,越来越多的设备选择将其作为默认的视频、照片输出编码。

但是相比起AVC,HEVC的推广速度慢了很多,

  1. 一个是它的编解码难度比H.264高了太多,但这点通过各路硬件编码器和软件优化逐渐化解掉了,目前常见的设备基本上支持HEVC的硬件编解码;
  2. 第二个就是HEVC高昂的专利费用问题,它并不是一个免费的编码格式,虽然个人使用它完全没有问题,但对于想要兼容它的厂商来说,这笔高昂的专利费用足以让他们却步,尤其是崇尚自由开放的互联网市场。于是,我们看到众多厂商选择了免费开放的VPx系列编码,以及系列的后继者——AV1。

Apple 生态中的 HEVC 支持

Apple 现在支持的 HEVC Codec(编解码器)类型为 kCMVideoCodecType_HEVC ,即 hvc1 ,对应的 Profiles 为 Main, Main Still Picture 及 Main 10 (即对应 HEVC Version 1 Profiles 中所有的内容),相应的文件封装格式(容器)还是我们熟悉 QuickTime Movie (.mov) 及 ISO MPEG-4 (.mp4) 。你可以在新的 iOS 11, tvOS 11 及 macOS 10.13 中使用它。

HEVC 的硬件要求

软编码 /软解码 是指使用 CPU 来完成编解码运算,硬编码/硬解码 是指使用非 CPU 进行解码运算,常见的就是使用 GPU。相对而言,硬编码/硬解码 能够省电、减少设备发热,对于移动设备延长续航有很大的作用。当前如果要在 iOS 10 或者更早设备上解码 HEVC 资源,一般是使用 FFmpeg 这一类的软解码方案(但请注意它的 License 是 LGPL )。

解码 Decode
新系统下所有 iOS / tvOS / macOS 设备均可执行 8-bit / 10-bit 的软解;在 iOS (tvOS) 上至少需要 A9 芯片来执行 8-bit / 10-bit 硬解码(也就是 iPhone 6s 之后的机型,猜测 9 月也会发布带 A9 / 4K 的 tvOS 新机型),而在 macOS 上至少需要酷睿 6 代来硬解码 8-bit 的资源,或者酷睿 7 代来硬解码 10-bit 的资源。

编码 Encode
新系统下,iOS 需要至少 A10 芯片来完成 8-bit 资源的硬编码( iPhone 的相机现在仅支持 8-bit 视频的拍摄,有兴趣可以研究一下使用 AVCaptureVideoDataOutput 来接收相机输出时的 videoSettings);在 macOS 上,至少需要酷睿 6 代来硬编码 8-bit 的资源,而 10-bit 的软编码则可以在所有的 Mac 上完成。

如果你想要查询当前设备是否支持硬件编码,可以使用下面这个新增的方法来查询。但是当前的测试版似乎还是存在 Bug,笔者使用了安装 iOS 11 beta 2 的 iPhone 7 Plus 及 iPad Pro 10.5 进行测试,均返回了 false ,而按照 Apple 给出的资料,应当是可以支持硬解的 🤷‍♂️。

1
2
// 测试设备 iPhone 7 Plus / iOS 11 beta 2 / Xcode 9 beta 2 / Swift 4
let isSupport = VTIsHardwareDecodeSupported(kCMVideoCodecType_HEVC) // false

HEIF 基本介绍

HEIF(High Efficiency Image File) 是一种图像文件封装格式, HEIF 相比 JEPG 能够提高 2 倍的压缩率,而且支持 HEVC 作为其压缩的编解码器;支持透明通道与深度;支持动画(比如动态 GIF , Live Photo);支持图像序列(比如照片的长曝光)。

HEVC 文件加载

与加载一张 JPEG 图像仅有扩展名的不同:

1
2
3
4
5
6
7
// Read a heic image from file
let inputURL = URL(fileURLWithPath: "/tmp/image.heic")
let source = CGImageSourceCreateWithURL(inputURL as CFURL, nil)
let imageProperties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [String: Any]
let image = CGImageSourceCreateImageAtIndex(source, 0, nil)
let options = [kCGImageSourceCreateThumbnailFromImageIfAbsent as String: true, kCGImageSourceThumbnailMaxPixelSize as String: 320] as [String: Any]
let thumb = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary)
HEIF 文件写入

与写入一张 JPEG 图像也仅有扩展名的区别:

1
2
3
4
5
6
7
8
9
10
// Writing a CGImage to a HEIC file
let url = URL(fileURLWithPath: "/tmp/output.heic")
guard let destination = CGImageDestinationCreateWithURL(url as CFURL,
AVFileType.heic as CFString, 1, nil)
else {
fatalError("unable to create CGImageDestination")
}

CGImageDestinationAddImage(imageDestination, image, nil)
CGImageDestinationFinalize(imageDestination)
编辑 HEIF 照片,并存为 JPEG 格式
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Editing a HEIF photo -- save as JPEG
func applyPhotoFilter(_ filterName: String, input: PHContentEditingInput, output: PHContentEditingOutput, completion: () -> ()) {

guard let inputImage = CIImage(contentsOf: input.fullSizeImageURL!)
else { fatalError("can't load input image") }
let outputImage = inputImage
.applyingOrientation(input.fullSizeImageOrientation)
.applyingFilter(filterName, withInputParameters: nil)

// Write the edited image as a JPEG.
do {
try self.ciContext.writeJPEGRepresentation(of: outputImage,
to: output.renderedContentURL, colorSpace: inputImage.colorSpace!, options: [:])
} catch let error { fatalError("can't apply filter to image: \(error)") }
completion()
}
编辑 HEVC 视频,并使用 H.264 编码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Editing an HEVC video -- save as H.264
func applyVideoFilter(_ filterName: String, input: PHContentEditingInput, output: PHContentEditingOutput, completionHandler: @escaping () -> ()) {

guard let avAsset = input.audiovisualAsset
else { fatalError("can't get AV asset") }
let composition = AVVideoComposition(asset: avAsset, applyingCIFiltersWithHandler: { request in
let img = request.sourceImage.applyingFilter(filterName, withInputParameters: nil)
request.finish(with: img, context: nil)
})
// Export the video composition to the output URL.
guard let export = AVAssetExportSession(asset: avAsset, presetName: AVAssetExportPresetHighestQuality)
else { fatalError("can't set up AV export session") }
export.outputFileType = AVFileType.mov
export.outputURL = output.renderedContentURL
export.videoComposition = composition
export.exportAsynchronously(completionHandler: completionHandler)
}

VPx系列与AV1:以免费为卖点

VPx系列编码实际上已经有很长的历史了。它的前身是On2 Technologies公司的TrueMotion系列视频编码,在开发TrueMotion VP8编码时,公司被Google收购了。在Google的介入下,VP8从原本的专有技术变成了开放技术,在BSD许可证下面进行开源。

从技术角度来说,VP8采用的技术是类似于H.264的。虽然在我们看到的宣传中,VP8拥有比H.264更佳的压缩效率,但在实际应用中,由于它在设计上有一定的瑕疵,表现并不如H.264,最终它虽然进入了Web标准,但也没见有人用它,反而是由它的帧内压缩技术提取而成的WebP受到了欢迎。

VP8的表现并不理想,Google很快就推出了它的继任者——VP9。这次,他们参考的是HEVC,设计目标同样是高分辨率下的高效编码。VP9中的一些设计是受到了HEVC的影响的,比如说同样最大为64x64的超级块(Super Block)。最终VP9达成的结果是提供了比VP8高达50%的效率提升。看起来它能够和HEVC比肩了,但是它也遇到了和VP8相似的问题,推广不开。VP9的应用范围实际也局限在Google自家的Youtube中,只能说是缺少实际应用场景。

但很快,一些厂商认识到HEVC高昂专利费用带来的弊端,他们决定创立一个开放联盟,推广开放、免费的媒体编码标准。这个联盟就是开放媒体联盟(Alliance for Open Media),创始成员有Amazon、Cisco、Google、Intel、Microsoft、Mozilla和Netflix这些我们熟悉的大公司,而后加入的还有苹果、ARM、三星、NVIDIA、AMD这些同样耳熟能详的公司。

Google将他们还在开发中的VP10贡献了出来作为联盟新编码的基础,很快,名为AV1的编码诞生了。在Facebook的测试中,它分别比VP9和H.264强上34%、46.2%,这次看上去是真的达到HEVC的级别了。

各种编码标准对比

编码标准 压缩效率 计算复杂度 专利许可费用 广泛支持情况
H.264 (AVC) 中等 较低 有专利费用 广泛支持
H.265 (HEVC) 较高 较高的专利费用 逐渐普及
VP9 较高 无专利费用(Google许可) 有限支持
VP10 更高 更高 无专利费用(Google许可) 尚未广泛支持
AV1 更高 更高 无专利费用(AOMedia成员 正在普及
AVS3 较高 收费 中国市场支持

H.264

简介

国际上制定视频编解码技术的组织有两个,一个是“国际电联(ITU-T)”,它制定的标准有 H.261、H.263、H.263+ 等,另一个是“国际标准化组织(ISO)”它制定的标准有 MPEG-1、MPEG-2、MPEG-4 等。而 H.264 则是由两个组织联合组建的联合视频组(JVT)共同制定的新数字视频编码标准,所以它既是 ITU-T 的 H.264,又是 ISO/IEC 的 MPEG-4 高级视频编码(Advanced Video Coding,AVC)的第 10 部分。

H.264/AVC标准在当前视频应用场景中仍然是应用最广、兼容性最高的视频编码标准,因此任何视频产品如果希望在支持最大范围用户流畅使用的同时保障视频质量,H.264/AVC软件编解码必不可缺。

编码过程和原理

H.264 是最常见的编码标准, 编码首先要把视频帧划分帧不同的类型

划分帧类型

  1. 将一串连续的相似的帧归到一个图像群组(Group Of Pictures,GOP)
  2. GOP中的帧可以分为3种类型
    • I帧(I Picture、I Frame、Intra Coded Picture),译为:帧内编码图像,也叫做关键帧(Keyframe)
      • 是视频的第一帧,也是GOP的第一帧,一个GOP只有一个I帧
      • 编码- 对整帧图像数据进行编码
      • 解码- 仅用当前I帧的编码数据就可以解码出完整的图像
      • 是一种自带全部信息的独立帧,无需参考其他图像便可独立进行解码,可以简单理解为一张静态图像
    • P帧(P Picture、P Frame、Predictive Coded Picture),译为:预测编码图像
      • 编码- 并不会对整帧图像数据进行编码
      • 以前面的I帧或P帧作为参考帧,只编码当前P帧与参考帧的差异数据
      • 解码- 需要先解码出前面的参考帧,再结合差异数据解码出当前P帧完整的图像
    • B帧(B Picture、B Frame、Bipredictive Coded Picture),译为:前后预测编码图像
      • 编码- 并不会对整帧图像数据进行编码
        • 同时以前面、后面的I帧或P帧作为参考帧,只编码当前B帧与前后参考帧的差异数据
        • 因为可参考的帧变多了,所以只需要存储更少的差异数据
      • 解码- 需要先解码出前后的参考帧,再结合差异数据解码出当前B帧完整的图像
    • 显示和编码顺序: I 帧-> P 帧 -> 中间的 B 帧 -> 下一个 P 帧 -> 中间的 B 帧 -> …

    • 帧类型的划分方式主要取决于:
  3. 编码设置:确定 GOP 的大小、P 帧与 B 帧之间的间隔。
  4. 视频内容:基于内容特征动态调整 GOP 结构和 P 帧、B 帧的间隔。例如,当存在高速运动或画面的剧烈变化时,可能会选择更多的 I 帧,以保持较好的图像质量。

GOP设置注意点

  1. GOP的长度表示GOP的帧数。GOP的长度需要控制在合理范围,以平衡视频质量、视频大小(网络带宽)和seek效果(拖动、快进的响应速度)等。
  2. 加大GOP长度有利于减小视频文件大小,但也不宜设置过大,太大则会导致GOP后部帧的画面失真,影响视频质量
  3. 由于P、B帧的复杂度大于I帧,GOP值过大,过多的P、B帧会影响编码效率,使编码效率降低
  4. 如果设置过小的GOP值,视频文件会比较大,则需要提高视频的输出码率,以确保画面质量不会降低,故会增加网络带宽
  5. GOP长度也是影响视频seek响应速度的关键因素,seek时播放器需要定位到离指定位置最近的前一个I帧,如果GOP太大意味着距离指定位置可能越远(需要解码的参考帧就越多)、seek响应的时间(缓冲时间)也越长

帧内/帧间预测

I帧采用的是帧内(Intra Frame)编码,处理的是空间冗余
P帧、B帧采用的是帧间(Inter Frame)编码,处理的是时间冗余

宏块划分

在进行编码之前,首先要将一张完整的帧切割成多个宏块(Macroblock),H.264中的宏块大小通常是16x16。

  1. 计算的简化与并行处理:将图像分解成较小的宏块,可以简化编码和解码过程中的预测、变换和量化等计算。此外,采用宏块处理也支持并行化处理,不同宏块间的数据处理可在多个处理单元上同时进行,从而能显著提高编码和解码的速度。
  2. 更精确的运动估计与补偿:视频编码中的帧间编码需要进行运动估计与补偿,以减少时间冗余。将图像划分为宏块可以针对每个局部区域进行更精细的运动估计,从而获得更准确的预测结果。
  3. 适应不同区域的编码优化:图像中不同区域的特征和纹理差异较大,例如局部光滑和局部纹理复杂的区域。将图像分解成较小的宏块有利于根据每个区域的特点进行优化编码。
  4. 降低空间冗余:局部区域(宏块)的像素之间的相关性较高。在该尺度上进行预测编码能有效降低空间冗余,达到更高的压缩效率。

帧内预测 (空间冗余)

一共有九种预测模式,通过选择模式从相邻已编码的像素计算预测值。

以4 * 4的宏块为例

1
2
3
4
150  140  130  120
160 150 145 130
145 159 161 128
170 160 140 110

水平预测(以左方相邻已编码像素为准)

1
2
3
4
150  150  150  150
160 160 160 160
145 145 145 145
170 170 170 170

垂直预测(以上方相邻已编码像素为准)

1
2
3
4
150  140  130  120
150 140 130 120
150 140 130 120
150 140 130 120

DC 模式(左边和上方相邻已编码像素的平均值)

1
2
3
4
150  150  150  150
150 150 150 150
150 150 150 150
150 150 150 150

接下来,将预测值与实际值进行比较,计算差值(残差)矩阵,例如用实际值减去水平预测值:

1
2
3
4
0  -10  -20  -30
0 -10 -15 -30
0 14 16 -42
0 -10 -30 -60

帧间预测(时间冗余)

用到了运动补偿和运动估计技术

1
2
3
4
5
6
7
参考帧  当前帧
■ ■ - - - -- - - - - -
■ ■ - - - -- - - - - -
- - - - - - --->- - ■ □ - -
- - - - - -- - □ ■ - -
- - - - - -- - - - - -
- - - - - -- - - - - -
  • 运动估计:我们在参考帧中寻找与当前帧中的正方形相似的区域。在这个例子中,我们可以看到正方形向下和向右移动了两个位置,但其形状发生了轻微变化。
  • 运动补偿:根据参考帧和当前帧之间的匹配结果,我们得到运动向量 (2, 2)。这意味着正方形沿着水平和垂直方向移动了两个单位距离。

生成预测块

1
2
3
4
5
6
- - - - - -
- - - - - -
- - ■ ■ - -
- - ■ ■ - -
- - - - - -
- - - - - -

计算当前帧和预测帧的残差

1
2
3
4
5
6
7
预测帧  当前帧残差矩阵
- - - - - -- - - - - -- - - - - -
- - - - - -- - - - - -- - - - - -
- - ■ □ - - -- - ■ ■ - - =- - - □ - -
- - □ ■ - -- - ■ ■ - -- - □ - - -
- - - - - -- - - - - -- - - - - -
- - - - - -- - - - - -- - - - - -

变换/量化

  1. 变换:通过预测并计算残差信息后,我们需要应用整数变换(例如离散余弦变换,DCT)将残差矩阵转换为频域。这有助于更有效地压缩数据,因为相关性较低的高频部分信息将在量化阶段被降低或丢弃。
  2. 量化:对已变换的残差系数进行量化。量化过程会根据量化参数(QP)压缩或丢弃较小的系数。降低数据精度有助于减小编码带宽并降低解码计算负担。

带来的问题:在变换和量化过程中,高频信息可能因为压缩而失真或被丢弃。这会导致视频中出现块效应,即边缘处的宏块边界变得明显可见。俗称马赛克
主要原因:DCT变换后的量化造成误差

数学好的可以看这个 Discrete cosine transform

滤波

为了减轻上述问题,许多编码算法(如 H.264)都引入了环路滤波器(in-loop filter)。环路滤波主要针对宏块边缘的块效应,降低边缘的可见性。滤波算法会检测边缘像素的强度和平滑程度,然后通过软化过渡,降低边缘明显的垂直和水平线条。这样可以改善视频画质,尤其是在低码率下。

还有一些编码器会对解码后的图像进行去噪,以减轻图像中的伪影、颜色变化和噪点。这使得解码后的图像在质量上更接近于原始图像。

滤波作为编码流程的补充,有助于在高压缩效率的情况下保持较好的视觉体验。

滤波前后对比

熵编码

熵编码模式的作用主要是利用数据的统计特性来进行无损压缩,降低视频编码的数据量,提高压缩效率。在不牺牲视频质量的前提下,这一阶段的压缩出色地减轻了带宽和存储的需求。在选择熵编码模式时,需权衡压缩效果与计算复杂度之间的关系,以便根据实际应用和设备性能找到合适的平衡点。

  1. CAVLC(Context-Adaptive Variable-Length Coding):CAVLC 是一种赫夫曼编码的扩展, 用短码来记录高频数据.用长码记录低频数据.
  2. CABAC(Context-Adaptive Binary Arithmetic Coding):CABAC 是一种基于二元算术编码的上下文自适应编码方法。CABAC 利用概率模型考虑相邻符号的依赖关系,并在算术编码的基础上进行优化。相比 CAVLC,CABAC 的压缩效果更好,但计算复杂度也更高,但是对于实时编码和解码是一个挑战。

码流结构

H.264 原始码流(裸流)是由一个接一个 NALU 组成,它的功能分为两层,VCL(视频编码层)和 NAL(网络提取层)。

VCL和NAL层

  • VCL: 视像编码层(Video Coding Layer, 简称VCL),包括核心压缩引擎和块,宏块和片的语法级别定义,设计目标是尽可能地独立于网络进行高效的编码,对视频原始数据进行压缩。
  • NAL: 网络抽象层(Network Abstraction Layer,简称NAL)。- 该层的作用是将视频编码数据根据内容的不同划分成不同类型的NALU,以适配到各种各样的网络和多元环境中。对VCL输出的SODB数据后添加结尾比特,一个比特 1 和若干个比特 0,用于字节对齐,称为RBAP,然后再在 RBSP 头部加上 NAL Header 来组成一个一个的NAL单元(unit)即 NALU 。

VCL 数据传输或者存储之前,会被映射到一个 NALU 中,H264 数据包含一个个 NALU。如下图:

一个NALU = 一组对应于视频编码的NALU头部信息 + 一个原始字节序列负荷(RBSP,Raw Byte Sequence Payload)

上图中的 NALU头 + RBSP 就相当与一个 NALU (Nal Unit), 每个单元都按独立的 NALU 传送。 其实说白了,H.264 中的结构全部都是以 NALU 为主的,理解了 NALU,就理解 H.264 的结构了。

码流分析

参数概念

H.264 码流第一个 NALU 是 SPS,第二个 NALU 是 PPS,第三个 NALU 是 IDR(即时解码器刷新)

  • IDR:一个序列的第一个图像叫做 IDR 图像(立即刷新图像),IDR 图像都是 I 帧图像。引入 IDR 图像是为了解码的重同步,当解码器解码到 IDR 图像时,立即将参考帧队列清空,将已解码的数据全部输出或抛弃,重新查找参数集,开始一个新的序列。这样,如果前一个序列出现重大错误,在这里可以获得重新同步的机会。IDR图像之后的图像永远不会使用IDR之前的图像的数据来解码。
  • SPS:序列参数集 SPS(Sequence Parameter Sets),存储的是一个序列的信息,包括有多少帧等
  • PPS:图像参数集 PPS(Picture Parameter Sets ),存储的一帧的信息。解码的时候必须获取到 SPS 和 PPS 的信息,才能对后面的数据进行解码。

组成单元

一个原始的H.264 NALU 单元常由 [StartCode] [NALU Header] [NALU Payload] 三部分组成

  • StartCode : Start Code 用于标示这是一个NALU 单元的开始,必须是”00 00 00 01” 或”00 00 01”
  • NALU Header 下表为 NAL Header Type
    • H.264NAL Header结构:
    • 我们假定一个头信息字节为0x67作为例子,根据查表得知提取到SPS类型
    • 从表中我们可以获知,NALU类型1-5为视频帧,其余则为非视频帧。在解码过程中,我们只需要取出NALU头字节的后5位,即将NALU头字节和0x1F进行与计算即可得知NALU类型,即:NALU类型 = NALU头字节 & 0x1F
  • Payload: 具体码流信息示意图

注意: 可以将start code理解为不同nalu的分隔符,header是某种类型的key,payload是该key的value.

码流格式

因为NALU长度不一,要写到一个文件中需要标识符来分割码流以区分独立的NALU,解决这一问题的两种方案,产生了两种不同的码流格式:

  • Annex-B:在每个NALU前加上0 0 0 1或者0 0 1,称作start code(起始码),如果原始码流中含有起始码,则起用防竞争字节:如将0 0 0 1处理为0 0 0 3 1。
  • AVCC:在NALU前面加上几个字节,用于表示整个NALU的长度(大端序,读取时调用CFSwapInt32BigToHost()转为小端),在读取的时候先将长度读取出来,再读取整个NALU。

除此之外,Annex-B和AVCC/HVCC对参数集的不同处理方式:

  • Annex-B:参数集当成普通的NALU处理,每个I帧前都需要添加SPS/PPS。
  • AVCC:参数集特殊处理,放在头部被称为extradata的数据中。

为什么不统一为一种格式?
我们知道视频分为本地视频文件和网络直播流,对于直播流,AVCC 格式只在头部添加了参数集,如果是中途进入观看会获取不到参数集,也就无法初始化解码器进行解码,而 Annex-B 在每个I帧前都添加了参数集,可以从最近的I帧初始化解码器解码观看。而 AVCC 只在头部添加参数集很适合用于本地文件,解码本地文件只需要获取一次参数集进行解码就能播放,所以不需要像Annex-B一样重复地存储多份参数集。

为什么要了解这两种格式?
因为Video Toolbox编码和解码只支持 AVCC/HVCC 的码流格式,而Android的 MediaCodec 只支持 Annex-B 的码流格式。因此在流媒体场景下,对于iOS开发而言,需要在采集编码之后转为Annex-B格式再进行推流,拉流解码时则需要转为AVCC/HVCC格式才能用Video Toolbox进行解码播放。

H.264编码库

编码库 开源与否 专利费用 平台支持 速度 效率 码率模式(CBR、VBR、CRF) 编码类型(软编/硬编)
x264 开源 需要支付专利费用 Windows, macOS, Linux CBR, VBR, CRF 软编
OpenH264 开源 由思科支付专利费用 Windows, macOS, Linux, Android, iOS CBR, VBR 软编
Intel Media SDK (Quick Sync Video) 无须支付专利费用 只支持带有 Intel 集成显卡的设备 极高 CBR, VBR, ICQ 硬编(基于 Intel GPU)
NVIDIA NVENC 无须支付专利费用 只支持特定的 NVIDIA 显卡设备 极高 CBR, VBR, CQP 硬编(基于 NVIDIA GPU)
AMD VCE 无须支付专利费用 只支持特定的 AMD 显卡设备 极高 CBR, VBR, CQP 硬编(基于 AMD GPU)
Apple VideoToolbox 无须支付专利费用 仅支持 macOS 和 iOS 设备 极高 CBR, VBR, CVBR, CRF 硬编(基于 Apple GPU)
编解码类型 编解码硬件 优点 缺点
软编解码 CPU 兼容性好,升级方便,支持所有视频格式,画质清晰。 性能较差的机型会发热或卡顿。
硬编解码 非CPU:GPU或专用的DSP、FPGA、ASIC芯片等 对CPU占用率低,不会出现手机发热等现象。 某些设备硬件不支持,兼容性不好。

目前大部分业务场景的编解码策略是:手机端采用硬编码生成视频文件发送给服务器,服务器进行软编转码为支持更多的格式或码率的视频,再分发给观看端。考虑到有些设备不支持硬编解码,通常需要软编解码做兜底。

  1. CBR(恒定比特率):以固定码率进行编码。这意味着视频中每个部分都具有相同的码率。CBR 适用于对输出文件大小有严格要求、需要稳定网络带宽的场景,例如实时视频传输、在线直播等。
  2. VBR(可变比特率):根据编码内容的复杂度自适应地调整码率。较复杂的部分会分配更高的码率,而较简单的部分会分配较低的码率。VBR 适用于需要平衡文件大小与视频质量的场景,如在线短视频、非实时视频存储等。
  3. CRF(恒定速率因子):通过设置一个速率因子,调整压缩效率以实现恒定的输出质量。CRF 侧重于尽可能保持视频品质,可能会导致文件大小不固定,适用于对压缩效果和质量有高要求的非实时场景,例如高清电影编码、视频编辑等。
  4. ICQ(固定品质量):Intel 编码器特有,它在指定的最大和最小品质限制范围内,根据编码内容的复杂度调整速率。这使得视频在保持较高质量的同时,也不会使码率过高。适用于有明确质量要求的场景,例如录制高清游戏视频等。
  5. CQP(恒定量化参数):位于 NVENC 与 AMD VCE 编码器,手动设置量化参数,实现固定质量的编码。适用于具有一定编码经验和对质量有明确要求的场景,例如录制游戏或教育视频等。
  6. CVBR(约束可变比特率):Apple 编码器特有,与 VBR 类似,但需要指定最大和最小码率范围,使得码率保持在这个范围内进行调整。适用于需要限制码率范围的应用场景,例如对输出文件大小有限制要求同时尽可能保证视频质量的场合。

参考资料

  1. 69 篇文章带你系统性的学习音视频开发
  2. 【WWDC21 10158】VideoToolbox 视频编码基础及其低延时新特性
  3. 音视频开发入门:音频基础
  4. 音视频开发入门:视频基础
  5. 音视频学习基础概念

JPEG 与有损压缩

JPEG 与有损压缩

一张图片通过屏幕展示一般需要解码和渲染两个步骤, 解码将图片原始数据转换为像素点, 渲染将像素点展示在屏幕上. 图片原本就是像素点组成, 展示在屏幕上仍旧是像素点, 为什么还需要解码呢?

我们以常用的 RGB 格式为例, 每一个像素点需要红绿蓝三个颜色各 8bit 来表示, 即一个像素点有 24 bit / 3 字节, 一张 1980*1080 的图片需要存储理论上需要 6.1MB(1980*1080*3/1024/1024=6.1MB), 但是实际一张这样的 PNG 图片大概只有 4MB, JPEG 格式的甚至只有 500KB, 这就是图片压缩的效果.

  • 一方面信息的存储有着巨大的冗余, 比如一张纯色图明显没必要存储所有的像素点, 这是无损压缩技术; (PNG)
  • 另一方面, 人眼会更在乎一张图的大体轮廓, 对于细节则较为不敏感, 因此保留轮廓信息忽略一些细节纹理可以进一步压缩, 这是有损压缩技术. (JPEG)

为什么颜色描述一般使用 8bit?
8bit 的 RGB 一共可以描述 2^24 个颜色, 大约 1678 万个颜色, 而学术界通常认为人眼能够识别的颜色种类最多有 1000 万种, 这个数字当然是因人而异的, 所以 8bit 实际已经能够匹配人眼的视觉范围. 而近些年有些支持 10bit / 12bit 的设备, 虽然可以形成更加细腻的视觉效果, 同时也会导致存储成本成倍增长, 更关键的是绝大部分屏幕也只支持 8bit 的色彩.

JPG 原理

JPEG 的工作原理大致分为以下几步:

  1. 颜色空间转换, 从RGB到Y,Cb,Cr
  2. 采样, YCbCr 可以减少 1/4 的信息
  3. 图像分割, 分割成8*8的小块
  4. DCT(Discrete cosine transform) 离散余弦变换
  5. Quantization(数据量化, 压缩很大一部分是在这里的)
  6. Huffman coding(对数据进行编码, 进一步压缩)

YUV / YCbCr 有损压缩

首先转换颜色空间, 一般开发中我们会使用 RGB 的颜色, JPEG 会将 RGB 转换为 YCbCr, 颜色空间转换的原因, 在文章How JPG Works这么解释:

JPG converts from RGB to Y,Cb,Cr color model; Which comprises of Luminance (Y), Chroma Blue (Cb) and Chroma Red (Cr). The reason for this, is that psycho-visual experiments (aka how the brain works with info the eye sees) demonstrate that the human eye is more sensitive to luminance than chrominance, which means that we may neglect larger changes in the chrominance without affecting our perception of the image. As such, we can make aggressive changes to the CbCr channels before the human eye notices.
JPG 将 RGB 转换为YCbCr 颜色空间, 包括亮度 (Y)、色度蓝 (Cb) 和色度红 (Cr)。原因是心理视觉实验(也就是大脑如何处理眼睛看到的信息)表明人眼对亮度比对色度更敏感, 这意味着我们可以忽略较大的色度变化并且不影响我们对图像的感知。因此, 我们可以在人眼注意到区别前对 Cb Cr 两个通道进行较大的更改。

YUV 和 RGB 是不同的色彩空间, RGB 是采用三种基本色为基础进行叠加, 从而产生不同的颜色; 而 YUV 是通过亮度和色差来描述颜色的颜色空间, Y表示明亮度(Luma), 也就是灰阶值, U 和 V 表示的则是色度(Chrominance)和浓度(Chroma), 作用是描述影像色彩及饱和度, 用于指定像素的颜色。因此对于黑白显示设备, 只需要去除色度分量, 只显示亮度分量即可。YUV细分的话有Y’UV / YUV / YCbCr / YPbPr等类型, 其中YCbCr主要用于数字信号。在流媒体领域中, YUV 其实就是指 YCbCr。

YCbCr的Y与YUV中的Y含义一致, Cb和Cr与UV同样都指色彩, Cb指蓝色色度, Cr指红色色度

RGB和YUV的换算公式如下:

1
2
3
Y =  0.299 R' + 0.587 G' + 0.114 B'
U = -0.147 R' - 0.289 G' + 0.436 B' = 0.492(B' - Y)
V = 0.615 R' - 0.289 G' + 0.436 B' = 0.877(R' - Y)

对于不同明亮度, UV 的表现如下:

YUV采样方式(即YCbCr)

YUV 码流的存储格式其实与其采样的方式密切相关, 主流的采样方式有三种, YUV4:4:4, YUV4:2:2, YUV4:2:0,
4:1:1含义就是:在2x2的单元中, 本应分别有4个Y, 4个U, 4个V值, 用12个字节进行存储。经过4:1:1采样处理后, 每个单元中的值分别有4个Y、1个U、1个V, 只要用6个字节就可以存储了

  • YUV 4:4:4采样, 每1个Y对应一组UV分量, 消耗 3*4 = 12byte
  • YUV 4:2:2采样, 每2个Y共用一组UV分量, 邻近4个像素点的亮度分量是被完整保留, Cb/Cr分量分别进行了下采样只保留了1/2, 而人眼看起来几乎不会察觉到变化. Y: 4byte, U: 2byte, V: 2byte, 共 8byte, 就这样简单粗暴的操作我们就节省出了1/3的存储空间
  • YUV 4:2:0采样, 每4个Y共用一组UV分量, Y: 4byte, U: 1byte, V: 1bit, 共 6byte, 对应地可以节省出 1/2 的空间.

但是这种通过色彩空间的压缩太过于简单粗暴了, 接下来我们看下 JPEG 是怎么通过数学工具做压缩的.

图像分割

然后将图像分成8*8的小块, 分成这么大是有原因的:

  1. 我们通常认为8x8像素块里没有太多的差异
  2. 太大的话进行矩阵操作复杂度上升
  3. 太小的话包含的信息太少, 在 DCT 中不能实现很好地压缩

JPEG编解码

上面的步骤都还是很常规的, 接下来才是 JPEG 关键的地方.

上图是 JPEG 简要的编解码框架, 其中熵编码是无损的, DCT变换理论上是无损的, 当然计算机计算过程存在着精度损失。真正的“有损压缩”步骤在于量化表进行量化这一步。

离散余弦变换(DCT)

离散余弦变换的原理是, 任何的复杂信号, 都可以透过傅里叶转换分解为基波和许多频率不同、幅度不等的谐波的叠加。

将它扩展到二维就可以对图像进行处理。其思想是, 任何8x8块可以表示为不同频率上加权余弦变换的和。
通俗点讲任何 8*8 的图像都可以由这64幅图像乘以不同的系数并叠加而得到。

像下面这张图, 转换为 YUV 后, YUV 的某一个分量的划分的小块, 有 8 行 8 列.为了 DCT 变化需要的定义域对称, 矩阵中数组需要减去 128, 得到一个 -128~127 的数字范围的新的矩阵, 然后开始 DCT 变换, JPEG 中使用的是 DCT II 的公式, 根据上图 64幅图像的叠加, 得到了 64 幅图系数, 即新的矩阵, 可以看到左上角的系数比较大, 即图像大体的轮廓这样的低频信号, 右下角数值比较小接近于 0, 表示图像的细节纹理等高频信号.

JPG

人眼一般对细节不敏感, 更注重图像的大体的轮廓, 因此可以将高频信号舍弃, 以达到压缩的目的, 及接下来的量化阶段.

量化

量化的实质就是将 DCT 转换后的系数除以一个整数, 当系数矩阵经过量化之后, 将系数由浮点数转变为整数, 并且在右下角出现大量连续的 0, 这才便于执行最后的编码, 这一步是有损的, JPEG 有两份量化表可供选择, 分别为亮度量化表和色度量化表(基于大量实验得出这个量化表视觉上的损失最小):

由于人眼对亮度更敏感, 左边的亮度量化表数值较小, 而色度量化表的数值比较大, 保证了色度量化后, 在右下角会出现大量 0, 进一步提高压缩比.

量化表会被保存在 JPEG 文件中, 一般会使用上述的标准量化表, 部分软件如Photoshop使用了自定义的量化表, 也即量化系数由他们自己实验得到的。

zigzag scan & 霍夫曼编码 (熵编码)

得到量化后的矩阵就要开始编码过程了, 首先要把二维矩阵变为一维数组, 这里采用了 zigzag 排列, 将相似频率组在一起:

在JPEG实现的时候, 对于DC系数(左上角的那一个元素)和AC系数(剩下的的63个元素)采用了不同的处理。
对DC系数使用DPCM(差分脉冲调制码), 用当前的DC减去前一个子图的DC, 然后使用Huffman编码。
对AC系数, 则使用Zig-Zag方式扫描, 然后使用Huffman编码。


得到序列之后再使用霍夫曼编码进一步压缩

EOB(End Of Block) 字段表示从字段开始后面全为0.

影响JPEG图片质量与文件大小因素总结

与图片的质量相关的因素有

  1. Quality factor
  2. 色度抽样, 即 YUV 的采样
  3. 图像处理, 包括平滑以及锐化,
    1. 平滑主要应用在噪声比较多的图片, 看起来更平滑一些, 噪声明显减少, 带来的问题是图片的细节会丢失
    2. 锐化主要是增强图片的细节, 让图像看起来更加清晰

与图片的大小相关的因素有

  1. Quality factor
  2. 色度抽样, 即 YUV 的采样
  3. 图像处理:上面提到的平滑与锐化操作也会带来文件大小的变化, 一般情况下平滑会减少文件的大小5%-10%;而锐化则会增加文件的大小, 比例约为5%-15%。
  4. Exif信息:Exif信息所占文件大小的比例有限, 但对于小图片来说, 这个占比是很高的, 有的甚至能达到60%, 如果业务不需要这些信息, 还是将其去除吧。通常在移动端的图片, 更适应这样操作。
  5. Huffman编码优化:这是编码优化的问题, 通常进行Huffman编码优化会带来0%-5%压缩率的提升。

其他图像压缩格式

GIF

GIF在头部信息中规定了一个调色板, 里面最多可以预先填充256种颜色, 但是8位真彩色可以表达的颜色范围有1670万种, 这里就涉及相近颜色的合并问题, 通用的图像处理库通常会采用八叉树结构对RGB数据进行统计和管理。

8位RGB颜色构成的八叉树共拥有8层,理论上八叉树最多可拥有19173961个结点。相近颜色合并的前提是需要定义颜色之间的“距离”。关于距离的定义我们这里不详细描述,直接给出颜色归并的原则:

  1. 首先归并深度最大的子树
  2. 深度相同则频度较小的子树先归并
  3. 取被归并子树结点的均值作为代表色

通过颜色归并操作,图像中会产生诸多颜色相同的块状区域,这非常有利于发挥GIF中LZW压缩算法的特点:对于连续重复出现的字节和字符串,LZW算法有着很高的压缩比(这部分属于熵编码即无损编码的范畴)。
由于颜色合并操作对于带宽节省作用非常明显,而且人们以前通常不会对GIF动图的单帧细节要求很高,针对GIF的降色操作已在现网应用多年。但现实是总会有一些bad case会让人觉得不太满意。

在将这张Thompson的GIF图降色至50色的时候,可以明显的发现Thompson的手臂和球衣肩部处已经呈现出非常明显的带状和块状失真。那么问题来了,在不增加颜色数的前提下有什么补救效果的办法呢?

Dither 抖动图像处理

这里需要提到名为Dither(抖动)的一类算法,这里利用了人眼视觉的错觉。据说这个名称来源于二战时期用于导航和轨道计算的计算机。当时的工程师发现这类机器在飞行甲板上运行的比在地面上更为准确,结果发现是甲板上震动降低了机器部件精度截断导致的错误,于是后来专门在这些计算设备中安装了震动马达,同时把这种马达的震动称为dither。

下面这张图清晰地演示出dither技术的视觉效果,当我们使用红色和蓝色两种色彩互相间隔地排成国际象棋似的方格盘时,随着方格被切分的越来越小,我们惊奇地发现我们看到了原来不存在的颜色-桃红色!

Dither使用效果前后对比图

然而利用dither的代价是比较大的,dither算法会显著提高GIF压缩的时间消耗,一般不建议在线服务使用这种技术,在异步服务中是值得考虑的选项。

Guetzli

Guetzli,这不是一个新的编码器。

Google瑞士研究院开发的Guetzli在开放之初通过自媒体的宣传,容易让人误以为Google开发了新的编码方法,但实际上单就编解码技术而言,Guetzli采用的就是标准的JPEG编码方法,没有任何创新。它的技术点在于它基于人眼的视觉特性定义了一个新的色彩空间XYB以及基于该色彩空间定义了一套自己的图像质量评价标准(IQA)Butteraugli。

Guetzli的压缩过程可以简单描述为两个阶段:阶段一,使用质量越来越低的量化表依次对原始JPEG重新量化编码,重编码的结果转化到XYB色彩空间并使用Butteraugli进行评价;阶段二,根据Butteraugli定义的图像质量评价标准,把标准所认为不重要的细节信息进行丢弃。这是一个不停迭代的过程,很像互联网产品的开发节奏,有一个小的想法,快速验证之后再回炉重造。通过不同数据集来源的测试,在同SSIM条件下,Guetzli与普通JPEG相比,可以带来大概0%~8%的带宽节省,与butteraugli条件下号称29%的节省相比,有着明显的缩水。

Guetzli在图像压缩领域的作用可以类比为初代小米之于手机市场,网易严选之于电商市场,傻瓜相机之于相机市场。“抛开庞杂的选项,快速给你一个还不算坏的选择”这就是Guetzli存在的意义。业务方经常遇到的难题其实是想在图像存储上节省存储费用和带宽费用,但是伴随而来的是众多图像压缩的参数选择困难,这对非专业人士来说会带来很大的困扰。这时候Guetzli站出来说,“你不用管这些了,给我一张图,我可以还给你一个虽然不算特别好,但是还不错的结果”,就问你用不用?

其他图片压缩格式

JPEG是九十年代制定的图片压缩格式,面对越来越大的图片和带宽压力,JPEG压缩越来越难以满足使用要求。因此,WebP和HEVC等压缩格式应运而生。其中WebP是Google在2010年发布的一种新型图片格式,其是基于视频编码标准VP8,支持无损和有损压缩。在有损压缩方面,同质量的WebP图片比JPEG小25-34%。

WebP相对于JPEG主要增加了预测编码,即在DCT变换之前进行预测编码,DCT变换和熵编码等和JPEG无明显差异。WebP 将图片划分为两个 8x8 色度像素宏块和一个 16x16 亮度像素宏块。在每个宏块内,编码器基于之前处理的宏块来预测冗余动作和颜色信息。通过图像关键帧运算,使用宏块中已解码的像素来绘制图像中未知部分,从而去除冗余数据,实现更高效的压缩。

而HEVC是最新一代的图片压缩格式,其基于视频编码标准H.265,比WebP有着更高的压缩率,HEVC的压缩率比WebP高31%,比JPEG高43%。HEVC与WebP的差异在于HEVC有更加灵活的宏块划分和更多种类的预测编码模式(WebP只有四种预测编码模式),常用的HEVC编码器包括HEIF、SharpP和WXAM,其中SharpP和WXAM是自研的HEVC编码器。

参考文章

JPG图片的编码与解码JS代码实现
JPG的工作原理
How JPG Works
PNG图片压缩原理解析
JPEG 图片压缩原理(一)
影像算法解析——JPEG 压缩算法
关于离散余弦变换(DCT)
从零开始手写jpeg编码器

[转载] WWDC23 10122 - 探索适用于现代 Web 的媒体格式

原文地址
摘要:本文将介绍 Safari 支持的媒体格式,包括图像和视频,并介绍了 Safari 17 中的新技术。文章还会讨论网站视频演变历程和最新技术 Managed Media Source API,实现自适应流媒体视频,提供更好的控制和更高效的性能。

本文是根据 Explore media formats for the web 进行撰写,旨在探索现代的图片和视频格式以及他们在 Web 中的应用。

文章架构
本文将介绍 Safari 支持的媒体格式,包括图像和视频,并介绍了 Safari 17 中的新技术。文章还会讨论网站视频演变历程和最新技术 Managed Media Source API,实现自适应流媒体视频,提供更好的控制和更高效的性能。

图像格式


多年以来,GIF、JPEG 和 PNG 等图像格式一直是互联网上最常用的图像格式。这些格式被广泛支持,可以在各种设备和浏览器上显示。然而,随着技术的不断进步,出现了新的更出色的图像格式,这些技术能够提供更好的视觉体验。

我们将按照时间顺序来进行介绍,并且对比 desktop(Chrome、Safari 和 IE)以及 mobile(Chrome for Android、Safari on iOS 和 Android Browser)的支持性进行说明。

我们以 _ 表示支持, GIF 和 JEPG 默认都支持。
根据已知 Can I use 上的数据整理。

GIF(1987) JPEG(1992) PNG(1996) WebP(2015) HEIC/HEIF(2015) AVIF(2019) JPEG-XL(2021)
Chrome - - 4 Chrome 32(9-31) * Chrome 85 *
Safari - - Safari3.1 Safari 16(14-15.6) Safari 17 Safari 16.4(16-16.3) Safari 17
IE - - 7 * * * *
Chrome for Android - - 114 114 * 114 *
Safari on iOS - - iOS 3.2 iOS 14 iOS 17 iOS 14 iOS 17
Android Browser - - Android 2.1 Android 4.2(4-4.1) * Android 5 *

接下来让我们简单的了解一下这些图像格式。

传统

作为被广泛使用的图像格式,GIF、JPEG 和 PNG 都拥有悠久的历史。

GIF

GIF 是 1987 年所引入的图像格式,以 8 位颜色(即 256 种颜色)展示相对清晰的图像。它实际上是一种压缩文档,采用 LZW 压缩算法 进行编码,有效地减少了图像文件在网络上传输的时间。在早期的互联网时代,因其体积小而成像相对清晰,GIF 大杀四方。

最适合在简单动画、网络梗和社交媒体内容中使用。

JPEG

JPEG 同样也是在 90 年代前后引入的图像格式。JPEG 有一种很好的特性是渐进式加载,可以在完全加载之前看到部分图像,在网络速度不是特别快的时候特别方便。它最适合用于照片和其他具有大量颜色和细节的图像。由于 JPEG 是一种有损的图像格式,这意味着在压缩过程中会丢失部分图像数据,特别适用于低对比,图像颜色过渡平滑,噪声多,且结构不规则的图片。

PNG

为了避免 GIF 所使用的 LZW 压缩算法 专利商业收费的影响,PNG 格式在 1995 年被创建出来,主要用于展示单张图像。PNG 最初的设计目的是替代 GIF,并且无需专利许可。与 GIF 相同,PNG 原生支持动画,但在实际应用中很少见到 PNG 用于动画。PNG 还支持透明度,这使得在叠加图像时能够展示更丰富的色彩。

目前,PNG 图片格式几乎支持所有的主流浏览器,除非你还想支持 IE6,IE6 不支持的是带有透明度的 PNG 图片。

但是 IE7 和 IE8 却对不带透明度的图片支持的没有那么好。😓

现代

除了上述三种传统的图像格式,还有四种更为现代的图像格式,它们分别是 WebP、JPEG-XL、AVIF 和 HEIC/HEIF。其中,JPEG-XL 和 HEIC/HEIF 将在 Safari 17 中首次得到支持。

WebP

WebP 是一种现代图像格式,使用先进的压缩算法实现更小的文件大小,而不会牺牲图像质量。在 Safari 14 和 macOS Big Sur 之后,你就可以在 Safari 中使用 WebP 来改善网站性能和加载时间,并可以用于动画。

WebP 旨在取代传统的三种图像格式。与 PNG 相比,同样大小的 WebP 无损图像文件能够平均减少 26%的大小。而且,只需增加 22%的文件大小,就可以使图像支持透明度。与 JPEG 相比,WebP 的平均大小能够减少 25%-34%,这样的压缩效果极为显著。这种格式提供了优秀的无损和有损压缩方式,使开发人员能够使用更小、更丰富的图像。

JPEG-XL

AVIF 和 JPEG XL 是旨在取代 WebP 的新一代图像格式。在 Safari 17 中,支持 JPEG-XL 是一个令人兴奋的新功能。JPEG-XL 旨在提供高压缩率和图像质量。它采用一种称为 模块熵编码 的新压缩算法,使得可以更灵活地调整压缩比。这使得 JPEG-XL 特别适合在网络连接缓慢的情况下加载图像,用户可以在整个图像完全加载之前就能看到部分内容。JPEG-XL 的一个关键特性是无损转换,也就是说,将现有的 JPEG 文件转换为 JPEG-XL 不会丢失任何数据,并且可以显著减小文件大小高达 60%。

JPEG-XL 是一个相对较新的图像格式,目前在支持它的浏览器中的使用率还比较低。根据 Can I Use上的数据,目前只有 Safari 17 支持该格式,而 Chrome 在去年停止了对 JPEG-XL 的支持。外界猜测 这主要是因为 Chrome 希望将精力集中在 WebP 和 AVIF 这两种图像格式上的发展上。

AVIF

AVIF 是一种现代图像格式,基于 AV1 视频格式。它使用 AV1 视频编解码器实现高压缩比,同时保持图像质量。AVIF 在几乎所有现代浏览器(除了 Edge)上都得到广泛 支持,并且非常适合实况照片。它还支持高达 12 位的色深。AVIF 提供了有损和无损压缩的选项,这使得它在文件大小方面具有优势。虽然 PNG 仍然是一种优秀的无损压缩格式,但 AVIF 是一种出色的替代品,特别适用于需要通过有损压缩来减小文件大小的情况。AVIF 不仅支持并行处理和动画,而且相对于 JPEG,它在图像压缩方面表现更出色,可以将文件大小减小约 10 倍。然而,需要注意的是,与 JPEG 相比,AVIF 不支持渐进式渲染。

WebP 确实是一个很好的图像格式,但 AVIF 和 JPEG-XL 提供了更好的性能和更高的压缩率,它们是未来 Web 图像优化的重要选择。

HEIC/HEIF

HEIC 是基于 HEVC 视频格式 的现代图像格式。Safari 17 增加了对 HEIC(也称为 HEIF)的支持,这是一种使用 HEVC 压缩算法实现小文件大小的图像格式,适用于 iPhone 和 iPad。在 WKWebView 中使用 HEIC 可以进行硬件加速和高效渲染,但需要注意 HEIC 现代图像格式不受所有浏览器和操作系统的支持。

如果你的应用支持 iOS 11 及以上版本,你可以考虑使用 HEIC 格式的图片来替代原本的 PNG 和 JPEG 格式。可以被认为是 iOS 平台上使用图片的 最佳实践。然而,在 Web 浏览器上,HEIC/HEIF 并不是首选格式,就像 JPEG-XL 一样,目前 支持 这种格式的浏览器非常有限,几乎没有支持的浏览器。

现代媒体格式和工具的应用

JPEG-XL、AVIF 和 HEIC 都具有一个重要的优势,即它们支持广色域和 HDR。广色域可以在文件中保留更多的颜色,并在屏幕上呈现更多的颜色,而 HDR(高动态范围)可以更准确地呈现黑暗部分的细节、亮部的亮度以及可接受的光线范围。这意味着在户外场景中可以呈现更丰富的色彩和细节,或在具有高对比度的明亮场景中获得更好的效果,或者在展示复杂肤色时获得更加真实和完美的效果。通过 HDR,图像可以更好地还原真实场景的光照情况,提供更具吸引力和逼真感的视觉体验。

为了在不支持这些格式的浏览器上提供正确的格式,你可以使用 HTML 中的picture元素来指定备用源,允许浏览器选择它支持的格式。建议你提供多个备用源,方便浏览器将按顺序查看可用格式列表,并优先使用最佳性能的格式。这样,你可以为用户提供正确的格式,而无需编写代码进行判断。

1
2
3
4
5
6
7
<picture>
<source srcset="images/large/sophie.heic" type="image/heic">
<source srcset="images/large/sophie.jxl" type="image/jxl">
<source srcset="images/large/sophie.avif" type="image/avif">
<img src="images/large/sophie.jpeg">
</picture>

视频(流媒体)


现在我们已经了解了可以使用的现代图像格式以及何时使用它们,让我们来看看视频,特别是自适应流媒体视频。视频在网站上的呈现方式的演变是一个引人入胜的过程,从网络早期开始,它已经走了很长的路。

流媒体技术:HLS vs MSE

随着移动设备的兴起,需要新技术来适应不同的屏幕大小和方向,苹果于 2009 年推出了 HTTP Live Streaming,通过将视频内容分成小的块或片段,支持自适应比特率流媒体。HLS 允许根据用户的互联网连接速度和设备功能提供最佳的视频质量。但是到今天只有 Safari 支持它。

但是如果想在其他浏览器上支持多端播放,你就只能选择 W3C 发布的 Media Source Extensions(MSE)。MSE 引入了对 MPEG-DASH 媒体流的支持,通过扩展视频和音频元素,无需使用其他插件就可以动态更改媒体流。这提供了自适应媒体流、实时流媒体、视频切割和视频编辑等功能。

虽然 Apple 在 Safari 8 上支持了 MSE,但仅限在 PC 端。这是因为 MSE 存在一些缺点:它在管理缓冲区级别、网络访问的时间和数量以及媒体变体选择方面并不出色。这些缺点在相对强大的设备上,如现代计算机上基本上不会产生问题。但在移动设备上的功耗比 HLS 本地播放器高得多,因为无法通过 MSE 实现所需的节电效果,MSE 在 iPhone 上并未被支持。通过对各种网站的所有测试都表明,启用 MSE 将损耗电池寿命。

因为 MSE 需要通过 JavaScript 控制媒体流的加载和播放,所以它会带来更多的资源消耗。具体来说,MSE 需要在接收端使用 JavaScript 解析媒体流,并将其分段缓存,这就需要更多的 CPU 和内存资源。同时,MSE 还需要与浏览器的媒体播放器进行交互,这也会带来额外的开销。

B 站开源的 flv.js 就是基于 MSE。

兼而得之的 Managed Media Source

有没有什么方法能既保留 MSE 的灵活又能兼具 HLS 的效率呢?答案是有的,就是 Managed Media Source (MMS) API。

MMS 是一个将更多对 MediaSource 及其相关对象的控制权交给浏览器的 API。它能够更容易地支持在能力受限的设备上进行流媒体播放,并允许用户代理根据可用内存和网络能力的变化做出相应的调整。

与旧版 MSE 相比,MMS 有以下几个区别:

  • 它通过告知网页何时是缓存更多媒体数据的最佳时间来减少功耗,可以让蜂窝调制解调器更长时间地进入低功耗状态,从而延长电池寿命。
  • 它可以智能地清除未使用或被丢弃的缓冲内存,使页面更加高效;它可以追踪缓冲区何时应该开始和停止,从而使页面更容易检测低缓冲区和完整缓冲区的状态。
  • 此外,MMS 还可以通过 5G 调制解调器发送媒体请求,从而使你的网站能够利用快速的 5G 网络快速加载媒体数据,并且对电源使用的影响最小。如果需要播放实时演出,MMS 还可以自动检测并切换到 LTE 或 4G(如果可用),以延长电池寿命。用户仍然可以控制每个分段的分辨率、下载方式和来源。

通过使用 MMS,你可以节省带宽和电池寿命,使用户在苹果设备上能够更长时间地观看视频。

从 MSE 到 MMS

从 MSE 迁移到 MMS 非常容易,只需要几个步骤。现在我们来简单介绍一下,正如前文所说,MSE 需要在客户端使用 JavaScript 解析媒体流,我们需要创建一个 js 文件,并在其中添加一个 runWithMSE 方法,runWithMSE 函数等待页面加载,创建视频元素,并将其附加到 MediaSource 对象上,最后将其附加到 HTML 的 video 元素上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function runWithMSE(testFunction, id = 'log') {
window.onload = function () {
var ms = new MediaSource();

var el = document.createElement("video");
el.src = URL.createObjectURL(ms);
el.preload = "auto";

document.body.appendChild(el);

testFunction(ms, el);
};
}

我们有两种方法来适配 MMS:一种是:首先需要确保 MMS 可用,然后将任何对 MediaSource 的调用替换为 ManagedMediaSource 本身。

1
2
3
4
5
6
7
8
9
10
11
12
// 确保 Managed Media Source 可用
function isMMSAvailable() {
return !!document.ManagedMediaSource;
}

function runWithMSE(testFunction, id = 'log') {
window.onload = function () {
var ms = isMMSAvailable() ? new ManagedMediaSource() : new MediaSource();
...
};
}

另一种更容易的方法是将 MediaSource 覆盖为 ManagedMediaSource。定义一个名为 getMediaSource() 的方法,并将其设置为 MediaSource

1
2
3
4
5
6
7
8
9
10
11
12
13
function getMediaSource() {
return self.ManagedMediaSource || self.MediaSource;
}
//
const MediaSource = getMediaSource();

function runWithMSE(testFunction, id = 'log') {
window.onload = function () {
var ms = new MediaSource();
...
};
}

然后我们在html文件中调用这个函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
runwithMSE(async function (source, video) {
video.controls = true;
await once(source, 'sourceopen');
var videosb = source.addSourceBuffer('video/mp4; codecs="mp4a.40.2,avc1.4d4015"');

source.onstartstreaming = async () => {
await loadData(videosb);
source.end0fStream();
await once(source, 'sourceended');
await video.play();
};
source.onendstreaming = () => {
// 已经有足够数据,可以进入低功耗模式
};
videos.onbufferedchange = () => {
// 检查源缓冲区中数据的变换
};
});

startstreaming 事件中,通知播放器开始获取新内容并将其添加到托管的 sourceBuffer 中。同时,还需要处理 endstreaming事件,以告知播放器何时需要停止获取新数据。需要注意的是,与 MSE 不同,你的 sourceBuffer 可能会在任何时候发生变化。为了避免可能导致播放暂停的情况,MSE 会定期检查缓冲区是否需要增加,并在附加新数据时增加缓冲范围。这样可以确保视频的平稳播放,同时保持足够的缓冲以应对网络延迟或其他因素引起的播放中断。所以,你还需要添加一个 bufferedchange 事件处理程序,以检查哪些数据已从源缓冲区中删除。遵循 MMS API 的规范,只在需要时附加数据,这样可以提高用户体验并延长设备电池的使用寿命。当然,如果你只关心苹果设备上的体验,请使用 HLS。

让视频 AirPlay

使用 HLS 的另一个好处是支持 AirPlay。使用支持 AirPlay 的媒体播放器 API,你可以让用户将视频/音频从他们的苹果设备扩展到 Apple TV、HomePod 或支持 AirPlay 的扬声器或智能电视,从而丰富你的应用程序。如果你有这样的需求,希望你的流媒体能够在 Safari 中使用本机支持的 HLS 并支持 AirPlay 功能,那么我们可以继续进行下一步操作。

需要明确的是,AirPlay 需要一个 URL,而 MSE 只能提供视频片段。那么只要视频资源能提供 AirPlay 所需的 HLS 视频流,那么就可以使用 AirPlay。我们可以像图片资源一样,为 video 元素提供一个备用资源,使其支持 AirPlay。

.m3u8 格式的视频文件是 Apple HLS 的基石,天然被 HLS 支持

1
2
3
4
5
6
7
8
9
10
11
// 支持 AirPlay
const videoSource1 = document.createElement('source');
videoSourcel.type = 'video/mp4';
videoSource1.src = URL.createObjectURL(mediasource);
video.appendChild(videoSource1);

const videoSource2 = document.createElement('source');
videoSource2.type = 'application/x-mpegURL';
videoSource2.sr = "http: //devimages.apple.com/iphone/samples/bipbop/bipbopall.m3u8";
video.appendChild(videoSource2);

Safari 会自动给这个视频资源添加 AirPlay 图标并允许用户 AirPlay 视频。然而,你需要为每个视频资源都提供一个备用资源,并且在创建 video 元素时添加备用资源。这种做法似乎有些繁琐。一个简单的方法是使用 HLS.js 进行视频播放。HLS.js 是一个 JavaScript 库,它实现了 HLS 客户端,依赖于 HTML5 视频和 MediaSource 扩展进行播放。它通过将 MPEG-2 传输流和 AAC/MP3 流转换为 ISO BMFF(MP4)片段来工作。当在浏览器中可用时,使用 Web Worker 异步执行转换。此外,HLS.js 还支持 HLS + fmp4。

如何使用 HLS.js 创建播放器完全取决于你的需求。你可以根据自己的需求来决定是使用 HLS.js 提供的功能,还是使用本机的 HLS 支持。如果你只需要在特定的环境中使用 HLS,并且希望利用浏览器的本机支持来实现更高的性能和兼容性,那么你可以选择使用浏览器的本机 HLS 功能。这取决于你的具体需求和优先考虑的因素。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<video id="video"></video>
<script>
var video = document.getElementById('video');
var videoSrc = 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8';
//
// 首先,先判断本机HLS是否支持
//
if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = videoSrc;
} else if (Hls.isSupported()) {
//
// 如果本机HLS并不支持,检查一下是否支持 HLS.js
//
var hls = new Hls();
hls.loadSource(videoSrc);
hls.attachMedia(video);
}
</script>

注意

为了保证用户的体验一致性,当你准备为视频资源支持 MMS 时,必须提供 AirPlay 源替代方案。如果没有提供替代方案,你必须通过 Remote Playback API 在媒体元素上显式禁用 AirPlay,即调用 disableRemotePlayback。

总结

确实,当今的互联网已经成为了一个以视觉为主的世界,大量的图片和视频被用于网站、社交媒体、广告等各种应用中。然而,传统的媒体格式,如 JPEG 和 PNG 等,存在着一些问题,例如文件大小过大、加载速度过慢、图片和视频质量不佳等。这些问题会影响用户体验和网站性能,使得网站变得缓慢和不稳定。

为了解决这些问题,开发者应该将目光投入到新的技术上。例如,JPEG-XL、AVIF 和 HEIC 等现代的图片和视频格式可以在保证高质量的同时,显著减少文件大小,从而提高加载速度和网站性能。除此之外,一些新的 Web 技术也可以帮助开发者更好地管理和优化媒体文件的加载和显示,从而提高用户体验和网站性能。

JPEG 与有损压缩

JPEG 与有损压缩

一张图片通过屏幕展示一般需要解码和渲染两个步骤, 解码将图片原始数据转换为像素点, 渲染将像素点展示在屏幕上. 图片原本就是像素点组成, 展示在屏幕上仍旧是像素点, 为什么还需要解码呢?

我们以常用的 RGB 格式为例, 每一个像素点需要红绿蓝三个颜色各 8bit 来表示, 即一个像素点有 24 bit / 3 字节, 一张 1980*1080 的图片需要存储理论上需要 6.1MB(1980*1080*3/1024/1024=6.1MB), 但是实际一张这样的 PNG 图片大概只有 4MB, JPEG 格式的甚至只有 500KB, 这就是图片压缩的效果.

  • 一方面信息的存储有着巨大的冗余, 比如一张纯色图明显没必要存储所有的像素点, 这是无损压缩技术; (PNG)
  • 另一方面, 人眼会更在乎一张图的大体轮廓, 对于细节则较为不敏感, 因此保留轮廓信息忽略一些细节纹理可以进一步压缩, 这是有损压缩技术. (JPEG)

为什么颜色描述一般使用 8bit?
8bit 的 RGB 一共可以描述 2^24 个颜色, 大约 1678 万个颜色, 而学术界通常认为人眼能够识别的颜色种类最多有 1000 万种, 这个数字当然是因人而异的, 所以 8bit 实际已经能够匹配人眼的视觉范围. 而近些年有些支持 10bit / 12bit 的设备, 虽然可以形成更加细腻的视觉效果, 同时也会导致存储成本成倍增长, 更关键的是绝大部分屏幕也只支持 8bit 的色彩.

JPG 原理

JPEG 的工作原理大致分为以下几步:

  1. 颜色空间转换, 从RGB到Y,Cb,Cr
  2. 采样, YCbCr 可以减少 1/4 的信息
  3. 图像分割, 分割成8*8的小块
  4. DCT(Discrete cosine transform) 离散余弦变换
  5. Quantization(数据量化, 压缩很大一部分是在这里的)
  6. Huffman coding(对数据进行编码, 进一步压缩)

YUV / YCbCr 有损压缩

首先转换颜色空间, 一般开发中我们会使用 RGB 的颜色, JPEG 会将 RGB 转换为 YCbCr, 颜色空间转换的原因, 在文章How JPG Works这么解释:

JPG converts from RGB to Y,Cb,Cr color model; Which comprises of Luminance (Y), Chroma Blue (Cb) and Chroma Red (Cr). The reason for this, is that psycho-visual experiments (aka how the brain works with info the eye sees) demonstrate that the human eye is more sensitive to luminance than chrominance, which means that we may neglect larger changes in the chrominance without affecting our perception of the image. As such, we can make aggressive changes to the CbCr channels before the human eye notices.
JPG 将 RGB 转换为YCbCr 颜色空间, 包括亮度 (Y)、色度蓝 (Cb) 和色度红 (Cr)。原因是心理视觉实验(也就是大脑如何处理眼睛看到的信息)表明人眼对亮度比对色度更敏感, 这意味着我们可以忽略较大的色度变化并且不影响我们对图像的感知。因此, 我们可以在人眼注意到区别前对 Cb Cr 两个通道进行较大的更改。

YUV 和 RGB 是不同的色彩空间, RGB 是采用三种基本色为基础进行叠加, 从而产生不同的颜色; 而 YUV 是通过亮度和色差来描述颜色的颜色空间, Y表示明亮度(Luma), 也就是灰阶值, U 和 V 表示的则是色度(Chrominance)和浓度(Chroma), 作用是描述影像色彩及饱和度, 用于指定像素的颜色。因此对于黑白显示设备, 只需要去除色度分量, 只显示亮度分量即可。YUV细分的话有Y’UV / YUV / YCbCr / YPbPr等类型, 其中YCbCr主要用于数字信号。在流媒体领域中, YUV 其实就是指 YCbCr。

YCbCr的Y与YUV中的Y含义一致, Cb和Cr与UV同样都指色彩, Cb指蓝色色度, Cr指红色色度

RGB和YUV的换算公式如下:

1
2
3
Y =  0.299 R' + 0.587 G' + 0.114 B'
U = -0.147 R' - 0.289 G' + 0.436 B' = 0.492(B' - Y)
V = 0.615 R' - 0.289 G' + 0.436 B' = 0.877(R' - Y)

对于不同明亮度, UV 的表现如下:

YUV采样方式(即YCbCr)

YUV 码流的存储格式其实与其采样的方式密切相关, 主流的采样方式有三种, YUV4:4:4, YUV4:2:2, YUV4:2:0,
4:1:1含义就是:在2x2的单元中, 本应分别有4个Y, 4个U, 4个V值, 用12个字节进行存储。经过4:1:1采样处理后, 每个单元中的值分别有4个Y、1个U、1个V, 只要用6个字节就可以存储了

  • YUV 4:4:4采样, 每1个Y对应一组UV分量, 消耗 3*4 = 12byte
  • YUV 4:2:2采样, 每2个Y共用一组UV分量, 邻近4个像素点的亮度分量是被完整保留, Cb/Cr分量分别进行了下采样只保留了1/2, 而人眼看起来几乎不会察觉到变化. Y: 4byte, U: 2byte, V: 2byte, 共 8byte, 就这样简单粗暴的操作我们就节省出了1/3的存储空间
  • YUV 4:2:0采样, 每4个Y共用一组UV分量, Y: 4byte, U: 1byte, V: 1bit, 共 6byte, 对应地可以节省出 1/2 的空间.

但是这种通过色彩空间的压缩太过于简单粗暴了, 接下来我们看下 JPEG 是怎么通过数学工具做压缩的.

图像分割

然后将图像分成8*8的小块, 分成这么大是有原因的:

  1. 我们通常认为8x8像素块里没有太多的差异
  2. 太大的话进行矩阵操作复杂度上升
  3. 太小的话包含的信息太少, 在 DCT 中不能实现很好地压缩

JPEG编解码

上面的步骤都还是很常规的, 接下来才是 JPEG 关键的地方.

上图是 JPEG 简要的编解码框架, 其中熵编码是无损的, DCT变换理论上是无损的, 当然计算机计算过程存在着精度损失。真正的“有损压缩”步骤在于量化表进行量化这一步。

离散余弦变换(DCT)

离散余弦变换的原理是, 任何的复杂信号, 都可以透过傅里叶转换分解为基波和许多频率不同、幅度不等的谐波的叠加。

将它扩展到二维就可以对图像进行处理。其思想是, 任何8x8块可以表示为不同频率上加权余弦变换的和。
通俗点讲任何 8*8 的图像都可以由这64幅图像乘以不同的系数并叠加而得到。

像下面这张图, 转换为 YUV 后, YUV 的某一个分量的划分的小块, 有 8 行 8 列.为了 DCT 变化需要的定义域对称, 矩阵中数组需要减去 128, 得到一个 -128~127 的数字范围的新的矩阵, 然后开始 DCT 变换, JPEG 中使用的是 DCT II 的公式, 根据上图 64幅图像的叠加, 得到了 64 幅图系数, 即新的矩阵, 可以看到左上角的系数比较大, 即图像大体的轮廓这样的低频信号, 右下角数值比较小接近于 0, 表示图像的细节纹理等高频信号.

JPG

人眼一般对细节不敏感, 更注重图像的大体的轮廓, 因此可以将高频信号舍弃, 以达到压缩的目的, 及接下来的量化阶段.

量化

量化的实质就是将 DCT 转换后的系数除以一个整数, 当系数矩阵经过量化之后, 将系数由浮点数转变为整数, 并且在右下角出现大量连续的 0, 这才便于执行最后的编码, 这一步是有损的, JPEG 有两份量化表可供选择, 分别为亮度量化表和色度量化表(基于大量实验得出这个量化表视觉上的损失最小):

由于人眼对亮度更敏感, 左边的亮度量化表数值较小, 而色度量化表的数值比较大, 保证了色度量化后, 在右下角会出现大量 0, 进一步提高压缩比.

量化表会被保存在 JPEG 文件中, 一般会使用上述的标准量化表, 部分软件如Photoshop使用了自定义的量化表, 也即量化系数由他们自己实验得到的。

zigzag scan & 霍夫曼编码 (熵编码)

得到量化后的矩阵就要开始编码过程了, 首先要把二维矩阵变为一维数组, 这里采用了 zigzag 排列, 将相似频率组在一起:

在JPEG实现的时候, 对于DC系数(左上角的那一个元素)和AC系数(剩下的的63个元素)采用了不同的处理。
对DC系数使用DPCM(差分脉冲调制码), 用当前的DC减去前一个子图的DC, 然后使用Huffman编码。
对AC系数, 则使用Zig-Zag方式扫描, 然后使用Huffman编码。


得到序列之后再使用霍夫曼编码进一步压缩

EOB(End Of Block) 字段表示从字段开始后面全为0.

影响JPEG图片质量与文件大小因素总结

与图片的质量相关的因素有

  1. Quality factor
  2. 色度抽样, 即 YUV 的采样
  3. 图像处理, 包括平滑以及锐化,
    1. 平滑主要应用在噪声比较多的图片, 看起来更平滑一些, 噪声明显减少, 带来的问题是图片的细节会丢失
    2. 锐化主要是增强图片的细节, 让图像看起来更加清晰

与图片的大小相关的因素有

  1. Quality factor
  2. 色度抽样, 即 YUV 的采样
  3. 图像处理:上面提到的平滑与锐化操作也会带来文件大小的变化, 一般情况下平滑会减少文件的大小5%-10%;而锐化则会增加文件的大小, 比例约为5%-15%。
  4. Exif信息:Exif信息所占文件大小的比例有限, 但对于小图片来说, 这个占比是很高的, 有的甚至能达到60%, 如果业务不需要这些信息, 还是将其去除吧。通常在移动端的图片, 更适应这样操作。
  5. Huffman编码优化:这是编码优化的问题, 通常进行Huffman编码优化会带来0%-5%压缩率的提升。

其他图像压缩格式

GIF

GIF在头部信息中规定了一个调色板, 里面最多可以预先填充256种颜色, 但是8位真彩色可以表达的颜色范围有1670万种, 这里就涉及相近颜色的合并问题, 通用的图像处理库通常会采用八叉树结构对RGB数据进行统计和管理。

8位RGB颜色构成的八叉树共拥有8层,理论上八叉树最多可拥有19173961个结点。相近颜色合并的前提是需要定义颜色之间的“距离”。关于距离的定义我们这里不详细描述,直接给出颜色归并的原则:

  1. 首先归并深度最大的子树
  2. 深度相同则频度较小的子树先归并
  3. 取被归并子树结点的均值作为代表色

通过颜色归并操作,图像中会产生诸多颜色相同的块状区域,这非常有利于发挥GIF中LZW压缩算法的特点:对于连续重复出现的字节和字符串,LZW算法有着很高的压缩比(这部分属于熵编码即无损编码的范畴)。
由于颜色合并操作对于带宽节省作用非常明显,而且人们以前通常不会对GIF动图的单帧细节要求很高,针对GIF的降色操作已在现网应用多年。但现实是总会有一些bad case会让人觉得不太满意。

在将这张Thompson的GIF图降色至50色的时候,可以明显的发现Thompson的手臂和球衣肩部处已经呈现出非常明显的带状和块状失真。那么问题来了,在不增加颜色数的前提下有什么补救效果的办法呢?

Dither 抖动图像处理

这里需要提到名为Dither(抖动)的一类算法,这里利用了人眼视觉的错觉。据说这个名称来源于二战时期用于导航和轨道计算的计算机。当时的工程师发现这类机器在飞行甲板上运行的比在地面上更为准确,结果发现是甲板上震动降低了机器部件精度截断导致的错误,于是后来专门在这些计算设备中安装了震动马达,同时把这种马达的震动称为dither。

下面这张图清晰地演示出dither技术的视觉效果,当我们使用红色和蓝色两种色彩互相间隔地排成国际象棋似的方格盘时,随着方格被切分的越来越小,我们惊奇地发现我们看到了原来不存在的颜色-桃红色!

Dither使用效果前后对比图

然而利用dither的代价是比较大的,dither算法会显著提高GIF压缩的时间消耗,一般不建议在线服务使用这种技术,在异步服务中是值得考虑的选项。

Guetzli

Guetzli,这不是一个新的编码器。

Google瑞士研究院开发的Guetzli在开放之初通过自媒体的宣传,容易让人误以为Google开发了新的编码方法,但实际上单就编解码技术而言,Guetzli采用的就是标准的JPEG编码方法,没有任何创新。它的技术点在于它基于人眼的视觉特性定义了一个新的色彩空间XYB以及基于该色彩空间定义了一套自己的图像质量评价标准(IQA)Butteraugli。

Guetzli的压缩过程可以简单描述为两个阶段:阶段一,使用质量越来越低的量化表依次对原始JPEG重新量化编码,重编码的结果转化到XYB色彩空间并使用Butteraugli进行评价;阶段二,根据Butteraugli定义的图像质量评价标准,把标准所认为不重要的细节信息进行丢弃。这是一个不停迭代的过程,很像互联网产品的开发节奏,有一个小的想法,快速验证之后再回炉重造。通过不同数据集来源的测试,在同SSIM条件下,Guetzli与普通JPEG相比,可以带来大概0%~8%的带宽节省,与butteraugli条件下号称29%的节省相比,有着明显的缩水。

Guetzli在图像压缩领域的作用可以类比为初代小米之于手机市场,网易严选之于电商市场,傻瓜相机之于相机市场。“抛开庞杂的选项,快速给你一个还不算坏的选择”这就是Guetzli存在的意义。业务方经常遇到的难题其实是想在图像存储上节省存储费用和带宽费用,但是伴随而来的是众多图像压缩的参数选择困难,这对非专业人士来说会带来很大的困扰。这时候Guetzli站出来说,“你不用管这些了,给我一张图,我可以还给你一个虽然不算特别好,但是还不错的结果”,就问你用不用?

其他图片压缩格式

JPEG是九十年代制定的图片压缩格式,面对越来越大的图片和带宽压力,JPEG压缩越来越难以满足使用要求。因此,WebP和HEVC等压缩格式应运而生。其中WebP是Google在2010年发布的一种新型图片格式,其是基于视频编码标准VP8,支持无损和有损压缩。在有损压缩方面,同质量的WebP图片比JPEG小25-34%。

WebP相对于JPEG主要增加了预测编码,即在DCT变换之前进行预测编码,DCT变换和熵编码等和JPEG无明显差异。WebP 将图片划分为两个 8x8 色度像素宏块和一个 16x16 亮度像素宏块。在每个宏块内,编码器基于之前处理的宏块来预测冗余动作和颜色信息。通过图像关键帧运算,使用宏块中已解码的像素来绘制图像中未知部分,从而去除冗余数据,实现更高效的压缩。

而HEVC是最新一代的图片压缩格式,其基于视频编码标准H.265,比WebP有着更高的压缩率,HEVC的压缩率比WebP高31%,比JPEG高43%。HEVC与WebP的差异在于HEVC有更加灵活的宏块划分和更多种类的预测编码模式(WebP只有四种预测编码模式),常用的HEVC编码器包括HEIF、SharpP和WXAM,其中SharpP和WXAM是自研的HEVC编码器。

参考文章

JPG图片的编码与解码JS代码实现
JPG的工作原理
How JPG Works
PNG图片压缩原理解析
JPEG 图片压缩原理(一)
影像算法解析——JPEG 压缩算法
关于离散余弦变换(DCT)
从零开始手写jpeg编码器

[转载] 用 HDR 图片点亮你的 App

原文链接

摘要:本文首先简要阐述了 HDR 相关的基本概念,例如 reference white、headroom 以及 tone mapping,然后回顾了苹果以往建立的 HDR 标准以及 HDR 渲染技术,最后重点介绍了今年新推出的 Adaptive HDR 标准以及在该标准下,如何对 HDR 图片进行读写、编辑和展示。本文基于WWDC24 - Use HDR for dynamic image experiences in your app整理。

WWDC24 主要介绍了苹果新推出的 HDR 图片标准:自适应 HDR (Adaptive HDR)。自适应 HDR 技术通过在同一文件中存储 SDR 基线图以及 HDR 显示所需要的元数据(metadata)和增益图(Gain Map),实现向前兼容 SDR 系统、解码器和应用程序的能力,且有助于在不同的显示环境下进行色调映射(tone mapping)。在介绍如何在自适应 HDR 标准下实现对 HDR 图片的读写和处理前,文本将简要介绍 HDR 的基本概念并回顾往年 WWDC 中与 HDR 有关的内容。

HDR 基本概念

HDR(High Dynamic Range 高动态范围)是一系列硬件和软件技术的集合,包括拍摄、后处理到最终的显示。与传统的 SDR (Standard Dynamic Range 标准动态范围)相比,它能够表达更大的亮度范围。如下图所示,人眼能够感知 10^5 尼特的阳光,传统的 SDR 所能表达的最大亮度只有 100 尼特,该亮度在 HDR 技术中被视为基线,称为 reference white。

descript

HDR 能够表达的亮度范围远超 SDR,其最大亮度与 reference white 之间的距离被称为 headroom,反映了表达 HDR 内容的能力。不同的显示设备拥有不同的 headroom。

descript

descript

除了显示设备外,HDR 图片也有 headroom 的概念,反映了图片中最大亮度与 reference white 之间的距离,当显示设备当前的 headroom 小于图片的 headroom 时,超出的部分就面临裁剪(clipping),无论其亮度值为多少,都以当前设备的最大亮度显示,这样的做法显然会造成这部分信息的丢失。

descript

为了避免这种情况,很自然地会想到将内容的亮度范围通过函数映射到当前显示设备支持的亮度范围,这也是
HDR 中的重要概念:色调映射(tone mapping)。通过这种技术,HDR 内容甚至可以显示在只支持 SDR
的设备上。为了在映射后获得更好的展示效果,发展出了多种色调映射技术,包括 Reinhard Tone Mapping,Filmic Tone Mapping,ACES(Acadamy Color Encoding System)等。

descript

下面的落日场景是 HDR 技术的典型体现。人眼能够同时捕获暗部(草地)和亮部(天空)的细节,但使用相机拍摄时,由于传感器可捕获的动态范围远远小于人眼,总会有一些细节因为曝光不足或曝光过度而丢失。

descript

descript

通过 HDR 拍摄技术,拍摄者可以捕捉多张不同曝光度的图像,通过软件进行合成,在拍摄设备不变的情况下,这个图片集合相比于单张图片,实际上扩大了捕获的亮度范围,最终在 HDR 设备上能够进行更保真的还原。

descript

往年 WWDC HDR 相关内容回顾

WWDC21 首先在 MacOS 上引入了 EDR(Extended Dynamic Range)技术,提供对 HDR 内容的渲染支持。在 WWDC22 上进一步将这种技术扩展到 iOS/iPadOS 中,并增加了参考模式(Reference Mode),为专业工作者提供更好的支持,同时也介绍了如何借助 Core Image、Metal 和 SwiftUI 显示 EDR 内容以及如何利用 AVFoundation 和 Metal 显示 HDR 视频,可以阅读 WWDC22 内参 - 【WWDC22 10113/10114/110565】在 iOS 上探索 EDR了解详细情况。

WWDC23 介绍了苹果推动建设的 HDR 图片技术标准:ISO/TS 22028-5。该标准定义了一种无损编码 HDR 内容的方式,遵循这种标准的 HDR 图片,也被苹果称为 ISO HDR。SwiftUI、UIKit 和 AppKit 能够以极简的 API 显示 ISO HDR 内容,并且通过设置 DynamicRange,将复杂的色调映射工作交给系统来完成。

1
2
3
4
5
let image = UIImage(contentsOfFile: pathToFile)

let imageView = UIImageView(image: image)

imageView.preferredImageDynamicRange = .high

与 ISO HDR 相对应的概念,是苹果一直以来在自己的相机和照片 App 中使用的 Gain Map HDR,以往 Gain Map HDR 图片只能在照片 App 中以 HDR 形态显示,从 iOS17 开始开发者可以通过最新提供的 API (例如 UIKit 中的 UIImageReader)来获取 Gain Map HDR 图片并在自己的 App 中展示。开发者一般无需关心图片本身是否是 HDR 的,但如果想对于不同动态范围的图像做更精细化的操作,也可以通过例如  isHighDynamicRange 等 API 来查询。

1
2
3
4
5
6
7
var config = UIImageReader.Configuration()

config.prefersHighDynamicRange = true

let image = reader.image(fileURL: url)

let isHighDynamicRange = image.isHighDynamicRange

Gain Map HDR 与当今 WWDC24 的主角 Adaptive HDR 有着相同的理念,后者正是苹果在尝试对前者进行标准化时的产物,下面将在介绍 Adaptive HDR 的过程中,简单叙述它们的理念以及两者的不同。

What’s New: Adaptive HDR

长久以来,传统 HDR 图片的一大问题就是兼容性差,兼容性同时体现在生产阶段和消费阶段。在生产阶段,传统的 JPEG 格式不能存储 HDR 图像,被广泛兼容的 JPEG 格式一般是 8bits 色深,而 HDR 图片通常要求 10bits 或 12bits。在消费阶段,HDR 图片必须经过”色调映射”,才能在 SDR 设备上展示,而这样的展示效果,往往还不如一张简单的 SDR 图片,因为每一张独一无二的图片都被应用了相同的映射规则。

Gain Map HDR 通过将图像信息拆分成以下三个部分存储,解决了上述兼容性问题:

一张基线图像(baseline image),通常是 SDR 图像,从而 JPEG

  • 格式也可以存储
  • 增益图,Gain Map HDR 的核心,并非实际的图像,而是包含了每一个像素应该如何在 SDR 和 HDR 之间转换的信息,相当于存储了图像在两种动态范围之间的映射关系
  • 元数据,反映了增益图是如何编码的以及在显式设备上优化渲染所需的关键信息

由于基线图片就是 SDR 图片,对于 SDR 设备的兼容性问题不复存在,甚至由于增益图实际上反映了 HDR 和 SDR 信号之间的插值关系,因此 headroom 不足的 HDR 设备,也能够在任意 headroom 下达到高质量的呈现效果。

descript

如今苹果尝试将 Gain Map HDR 技术标准化,这种标准化主要反映在两个方面,一是增益图的生产和消费过程中,HDR 和 SDR 信息应满足特定的数值关系,二是元数据的编码格式以及在不同文件格式(例如 HEIF、JPEG)中的存储应满足特定的规范。这种标准化后的技术被苹果成为 Adaptive HDR。从 iOS18 开始,传统的 Gain Map HDR 将全面迁移到符合新标准的 Adaptive HDR,iPhone 15 和 iPhone 15 Pro 将能够拍摄符合新标准的 Adaptive HDR 图片。

descript

除了 Adaptive HDR 外,今年苹果也针对传统的 ISO HDR 渲染进行了优化,色调映射从默认的 ITU Global Tone Mapping,升级为新研发的 Reference White Tone Mapping,这将减少亮部的裁剪并提升色彩还原的质量,这种新的技术将被用于 iOS,macOS,tvOS,watchOS 和 visionOS。

Adaptive HDR 的读取、编辑、保存与展示

descript

读取、编辑与保存

对于 Adaptive HDR,除了获取 SDR 图片和 HDR 图片,开发者还能够读取其增益图:

1
2
3
4
5
let sdrImage = CIImage(contentsOf: url)

let hdrImage = CIImage(contentsOf: url, options: [.expandToHDR : true])

let gain = CIImage:(contentsOf: url, options: [.auxiliaryHDRGainMap : true])

读取到这三种图片信息后,开发者可以应用如下不同的图片编辑策略

仅编辑 HDR 图像

仅编辑 HDR 实现起来最为简单,只需要保证滤镜能够对 HDR 内容生效,当然这也导致部分仅对 SDR 生效的滤镜在这种策略下无法使用。由于仅编辑了 HDR 图片,SDR 和增益图部分的信息已经无法与编辑后的 HDR 图片保持匹配,在保存时建议直接保存为 ISO HDR 图片。

1
2
3
4
5
6
7
8
9
10
11
12
13
// read and edit
let image = CIImage(contentsOf: url, options: [.expandToHDR : true])

let filter = CIFilter.vignetteEffectFilter()
filter.center = CGSize(width: image.extent.size.width/2, height:image.extent.size.height/2)
filter.radius = image.extent.size.height/2
filter.intensity = -1.0

filter.inputImage = image
let editedImage = filter.outputImage

// save
ctx.writeHEIF10Representation(of: editedImage, to: url, colorSpace: pqSpace)

同时编辑 SDR 和 HDR 图像

同时编辑 SDR 和 HDR 与单独编辑其中之一在实现上没有区别,需要注意在编辑过程中保持它们的协调。在保存的时候,系统能够根据编辑后的 SDR 和 HDR 图片,重新计算并生成增益图,从而可以继续保存为 Adaptive HDR 形式。

1
ctx.writeHEIF10Representation(of: editedSDR, to: url, colorSpace: p3Space, options: [.HDRImage: editedHDR])

同时编辑 SDR 和 增益图

在编辑增益图时需要注意它并非真正意义上的图片,它的线性尺寸只有基线 SDR 图片的一半,因此在如下示例代码中做拉伸变换时,首先要进行缩放变换;同时只有少部分滤镜能够作用于增益图,因此这种编辑策略通常用于像旋转、拉伸和裁剪这样简单的变换。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// read and edit
let sdr = CIImage(contentsOf: url)
var gain = CIImage:(contentsOf: url, options: [.auxiliaryHDRGainMap : true])
let xform = CGAffineTransform(scaleX: sdr.extent.size.width/gain.extent.size.width, y: sdr.extent.size.height/gain.extent.size.height)
gain = gain.transformd(by: xform)

let filter = CIFilter.strechCropFilter()
filter.size = CGSize(width: 1280, height: 720)
filter.cropAccount = 1.0

filter.inputImage = sdr
let editedSDR = filter.outputImage
filter.inputImage = gain
let editedGain = filter.outputImage

// save
ctx.writeHEIF10Representation(of: editedSDR, to: url, colorSpace: p3Space, options: [.HDRGainMapImage: editedGain])

下图是对这三种编辑策略各自优缺点的总结,相比于仅编辑 HDR 图片,后两种策略都能保持增益图的有效性从而能够保存为 Adaptive HDR 图片,进而在传播和展示时,维持出色的向前兼容和色调映射能力,其中编辑增益图的策略,由于只有有限的滤镜能够被应用,通常用于比较简单的场景。

descript

展示

上文提到,HDR 图片在不同 headroom 的显示设备上展示时,需要进行色调映射。通常来说,开发者只需要设置期望的 DynamicRange,就可以将这项复杂的工作交给系统处理:

1
2
3
let image = UIImageReader.default.image(contentsOfURL: url)
let imageView = UIImageView(image: image)
imageView.preferredImageDynamicRange = .high // .low or .constraindHigh

通过新的 API,开发者可以在展示 HDR 图片的时候指定 headroom 甚至增益图,从而获得更大的自由:

1
2
3
4
5
6
7
8
9
// display HDR image with current display headroom
let headroom = view.window.screen.currentEDRHeadroom
let tonemappedImage = editedHDR.applyingFilter("CIToneMapHeadroom", parameters: ["inputTargetHeadroom" : headroom])
cicontent.startTask(toRender: tonemappedImage, ...)

// display HDR image with SDR image, Gain Map and current display headroom
let headroom = view.window.screen.currentEDRHeadroom
let tonemappedImage = editedSDR.applyingGainMap(editedGain, headroom: headroom)
cicontext.startTask(toRender: tonemappedImage, ...)

结语

尽管 HDR 技术已经有多年的发展历程,苹果也从数年前的 iPhone 机型就开始就支持 HDR 图片的拍摄,但以往在系统 App 中只有照片能够展示 HDR 图片。从 iOS18/macOS15 开始,信息、快速查看、预览也加入了对 HDR 的支持。随着 Adaptive HDR 的推广,兼容性问题得到改善,开发者也对 HDR 图片的操纵拥有了更大的自由度,用户将能够在更多样的设备上体验到更多彩的影像世界。

参考资料

  1. Session 10177
  2. WWDC22 内参
  3. ISO/TS 22028-5

[转载] 轻踩一下就崩溃吗------踩内存案例分析

原文地址

踩内存问题分析成本较高,尤其是低概率问题困难更大。本文详细分析并还原了两个由于动态库全局符号介入机制(it’s a feature, not a bug)触发的踩内存案例。

踩内存不仅仅是调皮

进程是资源分配的最小单位,线程是cpu调度的最小单位。在同一个进程中各个线程是共用整个进程的地址空间的。

把进程的地址空间比作一张大画布,大家(各个线程)可以从这张大画布中裁剪出来一张张小画布使用,用完归还。每个人借还都在管理员那里登记好即可。同一张小画布也可以小A用完归还后小B使用。大家都遵守规则的情况下是非常和谐的。但是某一天,小A同学突然调皮了一下,在小B借用的小画布上乱涂乱画,甚至踩了个大脚印——这就是踩内存了!每一块画布除了画图区域外还有边框,小A同学如果对边框踩上几脚,归还的时候管理员也会非常无语的。

可以看到,小A的行为不仅仅是调皮了,简直是”恶劣行径”,很可能导致小B同学或者管理员同学”崩溃”大哭。

堆内存管理结构

malloc的数据结构如下图所示:

descript

我们申请的内存前后被metadata包裹,这里的metadata就是上文说的”画布边框”,内存释放的时候libc(管理员)是会对其进行检查,如果有问题libc会主动触发abort;申请到的内存可读写区域就是上文说的”画布的画图区域”。

1.2 踩内存后果

内存越界踩踏发生后比较严重的后果可能有如下几种情况:

  1. 内存释放时崩溃(发生abort而不是segment fault)——画布管理员哭了
  2. 多次崩溃调用栈不一致(发生segmentfault),具体看谁的内存被踩了——小B或者小C等被踩的同学哭了

这两种表现主要看越界的”步子”有多大(踩到了边框还是踩到了别人)。

相关问题的分析难点在于找到作案第一现场。而通常踩内存的问题导致崩溃后生成的coredump文件只是一个最终现场,从core中看到的触发崩溃的地方并不一定是罪魁祸首。

一个小案例

崩溃栈无法分析原因

版本提测之前总是能遇到一些突发崩溃,core文件解出的backtrace如下:

1
2
3
4
5
6
7
8

#0 0x00007f91396cd207 in raise () from /lib64/libc.so.6
#1 0x00007f91396ce8f8 in abort () from /lib64/libc.so.6
#2 0x00007f913970fd27 in __libc_message () from /lib64/libc.so.6
#3 0x00007f91397165d4 in malloc_printerr () from /lib64/libc.so.6
#4 0x00007f91397186cb in _int_free () from /lib64/libc.so.6
#5 0x00007f913ce85fa1 in Posxxx::releasexxx ()
#6 0x00007f913cdf53be in xxxProvider::~xxxProvider (this=0x151fbc0, __in_chrg=<optimized out>) at /root/workspace/feature/xxxProvider/xxxProvider.cpp:27

可以看到是在析构函数中释放内存是libc检测到异常主动触发了abort。难道我们要修改相关变量的内存释放逻辑吗?显然不是。这个core只是展示了进程崩溃的案发现场,但是并没有揭示为什么会这样。

valgrind报告指认真凶

释放内存导致崩溃只是最后的案发现场,内存释放逻辑是无辜的,真正行凶者早已逍遥法外。

运气比较好,这是一个必现问题。我们可以让凶手反复出手来对其实施抓捕。针对内存问题,asanvalgrind都是一把好手。

1
2
valgrind --tool=memcheck --leak-check=full --show-reachable=yes
--trace-children=yes ./Map /data/ /short.loc 2>&1\|tee valgrind.log

valgrind日志中看到:

valgrind: m_mallocfree.c:305 (get_bszB_as_is): Assertion ‘bszB_lo ==
bszB_hi’ failed.
valgrind: Heap block lo/hi size mismatch: lo = 1360, hi = 3212836864.
This is probably caused by your program erroneously writing past the
end of a heap block and corrupting heap metadata.

valgrind检查到了堆内存访问越界,并指出了发生非法内存写操作的地方–”Invalid
write of size 8”,相关问题出在xxx_define.h:380中的reset函数。

==92== Invalid write of size 8

==92== at 0x50F317C: reset (xxx_define.h:380)

==92== by 0x50F317C: xxxInfo (xxx_define.h:299)

==92== by 0x50F317C: xxx::xxx::xxxData() (xxxDefineBase.cpp:4)

==92== by 0x6089286: ??? (in /root/workspace/test/sdk/xxxResim/libxxxSimulater.so)

==92== by 0x400F8F2: _dl_init (in /usr/lib64/ld-2.17.so)

==92== by 0x4001159: ??? (in /usr/lib64/ld-2.17.so)

==92== by 0x2: ???

这里对struct xxxInfo的各个成员变量进行赋值操作导致了内存非法写入。

问题修复

进一步确认是因为回放工具(A.so)与定位模块(B.so)共用了相同的结构体定义,但是头文件却是各自维护一份,本次新需求定位模块在结构体中新增了字段,但是回放工具使用的结构体中未新增。

既然是两者代码不一致导致问题,我们只需要把两个头文件代码改成一样的不就解决问题了吗?

刨根问底

代码一致性问题解决后问题修复了,如果只是为了解决一次崩溃问题到此结束的话总感觉少了点什么。因为我们心中的疑问还没有解决——到底为什么会越界呢?

凶手找到了,但是他的动机是什么?又是什么让他产生了这样的动机?如果这些疑问不搞清楚的话我们只能结案一起凶杀案件,无法彻底解决其背后反映的社会问题。

深入分析

疑点重重

为什么valgrind的调用栈里显示的行号那么奇怪?明明是回放工具的库里面出的问题,但是行号看着更像是定位库最新代码,难道回放工具调用到了定位模块的代码(两个仓是隔离的,没有依赖关系)?

回顾本次问题,回放工具越界的原因是申请了一块小内存(按照老结构体定义size),但是赋值操作时却按照新结构体定义进行赋值,导致越界。两部分代码不在一个仓里,它是怎么用上新结构体定义的函数的呢?

灵光乍现

发现这部分回放工具复制过来的代码没有增加自己的命名空间。即回放工具模块和定位模块都有自己的xxx_define.h,这里面定义了结构体xxxInfo,并且包含了它的reset函数的实现(对结构体成员变量赋默认值),在xxxInfo构造函数中调用reset函数。

看到这里,一个专有名词突然闪过——“全局符号介入“!豁然开朗。是全局符号介入导致的A.so的代码用到了B.so中的函数定义!

拨云见日

下面验证下我们的猜想:

1)两个动态库中存在相同符号定义

回放工具的so为libxxxSimulater.so,定位模块是静态库,打进了libxxxSDK.so中,查看这两个库的符号,发现他们都有xxxInfo::reset()这个函数的符号。

descript

并且这两个都是弱符号(“W”),根据全局符号介入的原理,运行时按照动态库链接顺序查找调用函数的符号,然后加入全局符号表中,这之后的动态库中如果有相同的符号将被忽略。而动态库链接按照什么顺序则由不同的链接器内部实现,通常是广度优先遍历的顺序。

2)定位模块所在动态库先于回放工具库加载

使用ldd查看可执行程序xxxMap依赖库如下:
descript

这里从上到下的顺序就是运行时动态库的链接顺序。但是ldd官方接口说明并没有看到类似承诺。为了明确运行时各个库的装载、链接过程,我们可以使用LD_DEBUG功能更直观得查看。相关命令如下:

1
LD_DEBUG=files ./Map /data/ /data/short.loc

输出如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
560: file=libxxxSDK.so [0]; needed by ./Map [0]

560: file=libxxxSDK.so [0]; generating link map

560: dynamic: 0x00007f53a0aa1a10 base: 0x00007f53a02d9000 size:
0x00000000007e1f90

560: entry: 0x00007f53a04a05b0 phdr: 0x00007f53a02d9040 phnum: 7

...

560: file=libxxxSimulater.so [0]; needed by ./Map [0]

560: file=libxxxSimulater.so [0]; generating link map

...

由此可见,可执行程序Map先链接的libxxxSDK.so,后链接的libxxxSimulater.so。

ps:
LD_DEBUG是linux的一个环境变量,通过设置它我们可以看到链接器背后的很多操作,如果我们把它设置成symbols,即LD_DEBUG=symbols,则更能直接看到链接器是怎么查找到符号定义的。

真相大白

回放工具代码中分配内存使用的是自己的结构体定义大小(sizeof是编译时行为),而运行时由于全局符号介入跑到了定位模块定义的新结构体构造函数,以及reset方法,导致堆内存越界写。

所以,真相只有一个——事件还原如下:

step1.
定位模块和回放工具代码中各自有一份自己的xxx_define.h,大家各自include自己的头文件到各自的cpp中,而include的操作其实可以翻译成复制.h内容到cpp中。相当于大家各自定义了自己模块内部的struct xxxInfo以及它的构造函数和xxxInfo::reset()方法。

step2. 回放工具使用new xxxData()操作对xxxData进行了实例化,这一步new其实做了两件事。第一件事,在堆上分配了sizeof(xxxData)大小的内存,而xxxInfo是xxxData的成员,因此对sizeof(xxxData)的大小亦有贡献,sizeof是编译时行为,因此得到的size大小为回放工具代码中定义的老的xxxInfo的size;第二件事,调用了xxxData的构造函数。

step3.
xxxData构造函数执行时会先构造它的成员变量xxxInfo,而reset就是xxxInfo的构造函数中调用的函数。程序运行时链接器找到了libxxxSDK.so中xxxInfo::reset()函数的符号并加入全局符号表中,待libSimulater.so加载时其内部的xxxInfo构造和reset方法都被忽略了(参考《程序员的自我修养——链接、装载与库》中的介绍)。因此运行时回放工具执行的是libxxxSDK.so中定义的新结构体构造函数和reset函数。由于新结构体的reset函数里额外的成员赋值导致了写入内存超过了老结构体的size,堆内存越界!

用实践来检验真理**

实践是检验真理的唯一标准,下面我们写一个小demo来印证我们的分析结论。

/* a1_def.h */

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <iostream>

struct A {
A()
{
std::cout << "A() in a1_def.h" << std::endl;
}
~A()
{
std::cout << "~A() in a1_def.h" << std::endl;
}
int a;
};

/* a2_def.h */

1
2
3
4
5
6
7
8
9
10
11
12
13
struct A {
A()
{
std::cout << "A() in a2_def.h" << std::endl;
}

~A()
{
std::cout << "~A() in a2_def.h" << std::endl;
}

int a[8]; // 注意这里故意比a1_def.h中多了几个字节
};

/* b1.h */

1
void b1();

/* b2.h */

1
void b2();

/* b1.cpp */

1
2
3
4
5
6
#include "a1_def.h"
void b1()
{
struct A a;
std::cout << "b1(): sizeof A is: " << sizeof(a) << std::endl;
}

/* b2.cpp */

1
2
3
4
5
6
7
#include "b2.h"
#include "a2_def.h"
void b2()
{
struct A a;
std::cout << "b2(): sizeof A is: " << sizeof(a) << std::endl;
}

编译动态库:

1
2
3
4
g++ -fPIC -shared b1.cpp -o b1.so

g++ -fPIC -shared b2.cpp -o b2.so

/* main.cpp */

1
2
3
4
5
6
7
8
#include "b1.h"
#include "b2.h"
int main()
{
b2();
b1();
return 0;
}

生成可执行程序,先链接b1.so:g++ main.cpp b1.so b2.so -o main_b1_first_link

生成可执行程序,先链接b2.so: g++ main.cpp b2.so b1.so -o main_b2_first_link

执行结果如下:

descript

可以看到,当先链接b1.so时,函数b1() b2()中调用的都是a1_def.h中定义的struct
A的构造函数;当先链接b2.so时变成了调用的都是a2_def.h中定义的struct
A的构造函数。但是函数b1() b2()中的sizeof是编译时处理的,与链接无关,得到的都是各自include到的struct A的size,与动态库链接顺序无关。

至此,印证了本次的问题分析:malloc时使用sizeof得到的size分配了较小的内存,但是运行时使用的却是新定义的大结构体构造函数,导致内存越界写。

似曾相识

多数情况下,我们不会故意做类似上文这种相同代码拷贝两处的事情,或者说上述问题我们可以通过”小心”、”谨慎”来避免。但是有些时候类似问题却还是能在不经意间发生。

最近商用客户上报了一例全局符号介入导致的踩内存问题,但是其触发原因更加隐晦。

熟悉的配方–奇怪的崩溃栈

客户反馈新增模块加载后发生崩溃,崩溃栈很奇怪,在一堆客户模块调用流程中夹杂了一行高德动态库中STL模板类的调用。且最终崩溃的位置没有道理,怀疑发生踩内存。

一样的味道–全局符号介入

本次崩溃backtrace中,客户代码没有调用高德的API,但是backtrace中却出现了高德库中的stl模板类函数符号。有了第一个案例背景,我们自然会想到全局符号介入的原因,所以这个backtrace并不奇怪(it’s a feature, not a bug),但是问题是它发生了崩溃!

又一次真相大白

我们和客户共用相同版本的gcc编译工具链,各自对stl模板类的使用都是通过include头文件的方式完成的。大家include了一样的代码,那么即使出现全局符号介入,最终用哪个库的符号应该都是一样的才对。既然源码和工具链都一致,那么最终导致不一致的只有编译参数了。经排查,我们的编译参数跟客户的果然不一致!其中最危险的就是我们使用了-fshort-wchar而客户未使用。这将导致我们编译的代码中wchar_t类型大小是2字节,客户的代码中是4字节!进而导致发生踩内存,引发不可思议的崩溃。

show me the code

如下是最近客户上报问题的等价demo代码。

1
void funcA();
1
2
3
4
5
6
7
8
9
10
#include <iostream>
#include <vector>
#include <stddef.h>

void funcA()
{
std::cout << "funcA: size of wchar_t:" << sizeof(wchar_t) << std::endl;
std::vector<wchar_t> words = {};
size_t n = words.size();
}

将A.cpp编译成A.so(高德提供)。

1
g++ -fPIC -std=c++11 -fshort-wchar -shared -g A.cpp -o A.so

注意,此处编译参数添加了**-fshort-wchar**这会把wchar_t类型的size变成2字节,即funcA中打印的sizeof(wchar_t) = 2。

1
void funcB();
1
2
3
4
5
6
7
8
9
10
#include <iostream>
#include <vector>
#include <stddef.h>

void funcB()
{
std::cout << "funcB: size of wchar_t:" << sizeof(wchar_t) << std::endl;
std::vector<wchar_t> words = {};
size_t n = words.size();
}

编译B.so(客户内部模块):

1
g++ -fPIC -std=c++11 -shared -g B.cpp -o B.so

注意B.so编译时未使用-fshort-wchar。

由于stl中有很多模板类(本demo以vector为例),源文件中include相关模板类头文件后会将相关代码编译到当前so中,我们查看A.so中vector相关符号。

1
2
3
4
5
6
[root@4bad734105ec stl_template_demo]# nm A.so |grep vector
000000000000134a W _ZNKSt6vectorIwSaIwEE4sizeEv
00000000000012ec W _ZNSt6vectorIwSaIwEEC1Ev
00000000000012ec W _ZNSt6vectorIwSaIwEEC2Ev
0000000000001306 W _ZNSt6vectorIwSaIwEED1Ev
0000000000001306 W _ZNSt6vectorIwSaIwEED2Ev

使用c++filt翻译一下,这些被修饰的符号。

1
2
3
4
5
6
7
8
[root@4bad734105ec stl_template_demo]# c++filt _ZNKSt6vectorIwSaIwEE4sizeEv
std::vector<wchar_t, std::allocator<wchar_t> >::size() const
[root@4bad734105ec stl_template_demo]# c++filt _ZNSt6vectorIwSaIwEEC1Ev
std::vector<wchar_t, std::allocator<wchar_t> >::vector()
[root@4bad734105ec stl_template_demo]# c++filt _ZNSt6vectorIwSaIwEED1Ev
std::vector<wchar_t, std::allocator<wchar_t> >::~vector()
[root@4bad734105ec stl_template_demo]# c++filt _ZNSt6vectorIwSaIwEED2Ev
std::vector<wchar_t, std::allocator<wchar_t> >::~vector()

发现这些正是我们demo源码中用到的vector相关函数:vector构造、vector::size()接口、vector的析构。

注意nm的结果中显示,这些符号类型都是”W”,即弱符号(Weak),说明相关符号定义在A.so中存在一份,但是是否会真正使用要看链接结果,最终全局只会指定一份生效。同理,B.so中也存在一组vector相关的弱符号的定义。

接着看调用方代码(客户集成可执行程序)。

1
2
3
4
5
6
7
8
#include "A.h"
#include "B.h"

int main()
{
funcA();
funcB();
}

我们调整链接顺序,生成两个demo可执行程序。

1
2
3
4
// 先链接A.so
g++ main.cpp A.so B.so -g -o main_A_link_first
//先链接B.so
g++ main.cpp B.so A.so -g -o main_B_link_first

ldd查看链接顺序,如下图:

descript

有了上文的基础,我们可以预测,如果可执行程序先链接A.so,那么funcB()中vector相关的符号使用的就是A.so的了,我们可以通过gdb更清晰的明确这个结论。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Starting program: /root/workspace/test/stl_template_demo/main_A_link_first
warning: Error disabling address space randomization: Operation not permitted

Breakpoint 1, main () at main.cpp:6
6 funcA();
Missing separate debuginfos, use: debuginfo-install glibc-2.17-260.el7_6.3.x86_64 libgcc-4.8.5-36.el7_6.1.x86_64
(gdb) n
funcA: size of wchar_t:2
7 funcB();
(gdb) s
funcB () at B.cpp:7
7 std::cout << "funcB: size of wchar_t:" << sizeof(wchar_t) << std::endl;
(gdb) n
funcB: size of wchar_t:4
8 std::vector<wchar_t> words = {};
(gdb) s
std::vector<wchar_t, std::allocator<wchar_t> >::vector (this=0x7fff35804dd0) at /usr/include/c++/4.8.2/bits/stl_vector.h:249
249 : _Base() { }
(gdb) i register pc
pc 0x7f312b8f62f8 0x7f312b8f62f8 <std::vector<wchar_t, std::allocator<wchar_t> >::vector()+12>
(gdb) i symbol 0x7f312b8f62f8
std::vector<wchar_t, std::allocator<wchar_t> >::vector() + 12 in section .text of ./A.so

在单步调试中我们使用info symbol查看pc对应符号位置,果然B.so中的funcB中使用的vector构造函数来自A.so的代码段。

1
2
3
4
(gdb) i symbol 0x7f312b8f62f8

std::vector<wchar_t, std::allocator<wchar_t> >::vector() + 12 in
section .text of ./A.so

可是别忘了,A.so和B.so的编译参数不一致!!A.so中的wchar_t是2字节,B.so的wchar_t类型是4字节呀!!

1
2
3
[root@4bad734105ec stl_template_demo]# ./main_A_link_first
funcA: size of wchar_t:2
funcB: size of wchar_t:4

这就会导致引发踩内存问题。例如,分配的内存小,但是写数据是偏移的size大(这就与案例一类似了),导致踩坏到了别人的内存,触发崩溃。

整理回顾

本示例中,A.so来自高德团队,B.so和可执行程序由客户完成。A.so中的对外接口void funcA();非常简单,但是却不经意间额外导出了stl相关的符号,导致客户so中的符号被我们替换,而更为致命的是两个团队编译参数不一致导致wchar_t类型的size不一致,进而引发踩内存的稳定性问题。

该案例暴露了两个问题:

  • 我们的SDK额外导出了一些符号,更好的做法应该是使用编译参数-fvisibility=hidden默认隐藏所有符号,只针对性导出对外接口符号。
  • 同一个进程中的多方提供的动态库编译参数不一致。

针对本案例,拉齐多方编译参数是最佳解决方案。

默认隐藏其他符号是解决不了问题的,因为我们对外导出的接口可能也会使用stl模板类,例如void funcX(vector<wchar_t> data);但这并不是我们不使用-fvisibility=hidden的理由。

小结&感悟

踩内存问题分析成本较高,尤其是低概率问题困难更大。本文详细分析并还原了两个由于动态库全局符号介入机制(it’s a feature, not a bug)触发的踩内存案例。

之前老说不同库定义相同符号危害如何如何,这次看到了活生生的例子。以前看书的时候对全局符号介入、运行时绑定等装载、链接的各种概念没啥感觉,这次的案例真是书本知识的完美应用。问题想明白本质后感觉真是酣畅淋漓!

写在最后:

  • 不一致,是万恶之源。
  • 工程标准化,是解放生产力的良方。

参考链接

[转载] 深入理解RunLoop

深入理解RunLoop

转载自深入理解RunLoop, ibireme | 2015-05-18 | iOS, 技术

RunLoop 是 iOS 和 OSX 开发中非常基础的一个概念,这篇文章将从 CFRunLoop 的源码入手,介绍 RunLoop 的概念以及底层实现原理。之后会介绍一下在 iOS 中,苹果是如何利用 RunLoop 实现自动释放池、延迟回调、触摸事件、屏幕刷新等功能的。

RunLoop 的概念


一般来讲,一个线程一次只能执行一个任务,执行完成后线程就会退出。如果我们需要一个机制,让线程能随时处理事件但并不退出,通常的代码逻辑是这样的:

1
2
3
4
5
6
7
function loop() {
initialize();
do {
var message = get_next_message();
process_message(message);
} while (message != quit);
}

这种模型通常被称作 Event Loop。 Event Loop 在很多系统和框架里都有实现,比如 Node.js 的事件处理,比如 Windows 程序的消息循环,再比如 OSX/iOS 里的 RunLoop。实现这种模型的关键点在于:如何管理事件/消息,如何让线程在没有处理消息时休眠以避免资源占用、在有消息到来时立刻被唤醒。

所以,RunLoop 实际上就是一个对象,这个对象管理了其需要处理的事件和消息,并提供了一个入口函数来执行上面 Event Loop 的逻辑。线程执行了这个函数后,就会一直处于这个函数内部 “接受消息->等待->处理” 的循环中,直到这个循环结束(比如传入 quit 的消息),函数返回。

OSX/iOS 系统中,提供了两个这样的对象:NSRunLoop 和 CFRunLoopRef。
CFRunLoopRef 是在 CoreFoundation 框架内的,它提供了纯 C 函数的 API,所有这些 API 都是线程安全的。
NSRunLoop 是基于 CFRunLoopRef 的封装,提供了面向对象的 API,但是这些 API 不是线程安全的。

CFRunLoopRef 的代码是开源的,你可以在这里 http://opensource.apple.com/tarballs/CF/ 下载到整个 CoreFoundation 的源码来查看。

(Update: Swift 开源后,苹果又维护了一个跨平台的 CoreFoundation 版本:https://github.com/apple/swift-corelibs-foundation/,这个版本的源码可能和现有 iOS 系统中的实现略不一样,但更容易编译,而且已经适配了 Linux/Windows。)

RunLoop 与线程的关系


首先,iOS 开发中能遇到两个线程对象: pthread_tNSThread。过去苹果有份文档标明了 NSThread 只是 pthread_t 的封装,但那份文档已经失效了,现在它们也有可能都是直接包装自最底层的 mach thread。苹果并没有提供这两个对象相互转换的接口,但不管怎么样,可以肯定的是 pthread_tNSThread 是一一对应的。比如,你可以通过 pthread_main_thread_np()[NSThread mainThread] 来获取主线程;也可以通过 pthread_self()[NSThread currentThread] 来获取当前线程。CFRunLoop 是基于 pthread 来管理的。

苹果不允许直接创建 RunLoop,它只提供了两个自动获取的函数:CFRunLoopGetMain()CFRunLoopGetCurrent()。 这两个函数内部的逻辑大概是下面这样:

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
/// 全局的Dictionary,key 是 pthread_t, value 是 CFRunLoopRef
static CFMutableDictionaryRef loopsDic;
/// 访问 loopsDic 时的锁
static CFSpinLock_t loopsLock;

/// 获取一个 pthread 对应的 RunLoop。
CFRunLoopRef _CFRunLoopGet(pthread_t thread) {
OSSpinLockLock(&loopsLock);

if (!loopsDic) {
// 第一次进入时,初始化全局Dic,并先为主线程创建一个 RunLoop。
loopsDic = CFDictionaryCreateMutable();
CFRunLoopRef mainLoop = _CFRunLoopCreate();
CFDictionarySetValue(loopsDic, pthread_main_thread_np(), mainLoop);
}

/// 直接从 Dictionary 里获取。
CFRunLoopRef loop = CFDictionaryGetValue(loopsDic, thread));

if (!loop) {
/// 取不到时,创建一个
loop = _CFRunLoopCreate();
CFDictionarySetValue(loopsDic, thread, loop);
/// 注册一个回调,当线程销毁时,顺便也销毁其对应的 RunLoop。
_CFSetTSD(..., thread, loop, __CFFinalizeRunLoop);
}

OSSpinLockUnLock(&loopsLock);
return loop;
}

CFRunLoopRef CFRunLoopGetMain() {
return _CFRunLoopGet(pthread_main_thread_np());
}

CFRunLoopRef CFRunLoopGetCurrent() {
return _CFRunLoopGet(pthread_self());
}

从上面的代码可以看出,线程和 RunLoop 之间是一一对应的,其关系是保存在一个全局的 Dictionary 里。线程刚创建时并没有 RunLoop,如果你不主动获取,那它一直都不会有。RunLoop 的创建是发生在第一次获取时,RunLoop 的销毁是发生在线程结束时。你只能在一个线程的内部获取其 RunLoop(主线程除外)。

RunLoop 对外的接口


在 CoreFoundation 里面关于 RunLoop 有5个类:

  • CFRunLoopRef
  • CFRunLoopModeRef
  • CFRunLoopSourceRef
  • CFRunLoopTimerRef
  • CFRunLoopObserverRef

其中 CFRunLoopModeRef 类并没有对外暴露,只是通过 CFRunLoopRef 的接口进行了封装。他们的关系如下:
RunLoop_0

一个 RunLoop 包含若干个 Mode,每个 Mode 又包含若干个 Source/Timer/Observer。每次调用 RunLoop 的主函数时,只能指定其中一个 Mode,这个Mode被称作 CurrentMode。如果需要切换 Mode,只能退出 Loop,再重新指定一个 Mode 进入。这样做主要是为了分隔开不同组的 Source/Timer/Observer,让其互不影响。

CFRunLoopSourceRef 是事件产生的地方。Source有两个版本:Source0 和 Source1。
• Source0 只包含了一个回调(函数指针),它并不能主动触发事件。使用时,你需要先调用 CFRunLoopSourceSignal(source),将这个 Source 标记为待处理,然后手动调用 CFRunLoopWakeUp(runloop) 来唤醒 RunLoop,让其处理这个事件。
• Source1 包含了一个 mach_port 和一个回调(函数指针),被用于通过内核和其他线程相互发送消息。这种 Source 能主动唤醒 RunLoop 的线程,其原理在下面会讲到。

CFRunLoopTimerRef 是基于时间的触发器,它和 NSTimer 是toll-free bridged 的,可以混用。其包含一个时间长度和一个回调(函数指针)。当其加入到 RunLoop 时,RunLoop会注册对应的时间点,当时间点到时,RunLoop会被唤醒以执行那个回调。

CFRunLoopObserverRef 是观察者,每个 Observer 都包含了一个回调(函数指针),当 RunLoop 的状态发生变化时,观察者就能通过回调接受到这个变化。可以观测的时间点有以下几个:

1
2
3
4
5
6
7
8
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
};

上面的 Source/Timer/Observer 被统称为 mode item,一个 item 可以被同时加入多个 mode。但一个 item 被重复加入同一个 mode 时是不会有效果的。如果一个 mode 中一个 item 都没有,则 RunLoop 会直接退出,不进入循环。

RunLoop 的 Mode


CFRunLoopMode 和 CFRunLoop 的结构大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct __CFRunLoopMode {
CFStringRef _name; // Mode Name, 例如 @"kCFRunLoopDefaultMode"
CFMutableSetRef _sources0; // Set
CFMutableSetRef _sources1; // Set
CFMutableArrayRef _observers; // Array
CFMutableArrayRef _timers; // Array
...
};

struct __CFRunLoop {
CFMutableSetRef _commonModes; // Set
CFMutableSetRef _commonModeItems; // Set<Source/Observer/Timer>
CFRunLoopModeRef _currentMode; // Current Runloop Mode
CFMutableSetRef _modes; // Set
...
};

这里有个概念叫 CommonModes:一个 Mode 可以将自己标记为”Common”属性(通过将其 ModeName 添加到 RunLoop 的 “commonModes” 中)。每当 RunLoop 的内容发生变化时,RunLoop 都会自动将 _commonModeItems 里的 Source/Observer/Timer 同步到具有 “Common” 标记的所有Mode里。

应用场景举例:主线程的 RunLoop 里有两个预置的 Mode:kCFRunLoopDefaultModeUITrackingRunLoopMode。这两个 Mode 都已经被标记为Common属性。DefaultMode 是 App 平时所处的状态,TrackingRunLoopMode 是追踪 ScrollView 滑动时的状态。当你创建一个 Timer 并加到 DefaultMode 时,Timer 会得到重复回调,但此时滑动一个TableView时,RunLoop 会将 mode 切换为 TrackingRunLoopMode,这时 Timer 就不会被回调,并且也不会影响到滑动操作。

有时你需要一个 Timer,在两个 Mode 中都能得到回调,一种办法就是将这个 Timer 分别加入这两个 Mode。还有一种方式,就是将 Timer 加入到顶层的 RunLoopcommonModeItems 中。commonModeItems 被 RunLoop 自动更新到所有具有Common属性的 Mode 里去。

CFRunLoop对外暴露的管理 Mode 接口只有下面2个:

1
2
CFRunLoopAddCommonMode(CFRunLoopRef runloop, CFStringRef modeName);
CFRunLoopRunInMode(CFStringRef modeName, ...);

Mode 暴露的管理 mode item 的接口有下面几个:

1
2
3
4
5
6
CFRunLoopAddSource(CFRunLoopRef rl, CFRunLoopSourceRef source, CFStringRef modeName);
CFRunLoopAddObserver(CFRunLoopRef rl, CFRunLoopObserverRef observer, CFStringRef modeName);
CFRunLoopAddTimer(CFRunLoopRef rl, CFRunLoopTimerRef timer, CFStringRef mode);
CFRunLoopRemoveSource(CFRunLoopRef rl, CFRunLoopSourceRef source, CFStringRef modeName);
CFRunLoopRemoveObserver(CFRunLoopRef rl, CFRunLoopObserverRef observer, CFStringRef modeName);
CFRunLoopRemoveTimer(CFRunLoopRef rl, CFRunLoopTimerRef timer, CFStringRef mode);

你只能通过 mode name 来操作内部的 mode,当你传入一个新的 Mode nameRunLoop 内部没有对应 mode 时,RunLoop会自动帮你创建对应的 CFRunLoopModeRef。对于一个 RunLoop 来说,其内部的 mode 只能增加不能删除。

苹果公开提供的 Mode 有两个:kCFRunLoopDefaultMode (NSDefaultRunLoopMode) 和 UITrackingRunLoopMode,你可以用这两个 Mode Name 来操作其对应的 Mode

同时苹果还提供了一个操作 Common 标记的字符串:kCFRunLoopCommonModes (NSRunLoopCommonModes),你可以用这个字符串来操作 Common Items,或标记一个 Mode 为 Common。使用时注意区分这个字符串和其他 Mode name

RunLoop 的内部逻辑


根据苹果在文档里的说明,RunLoop 内部的逻辑大致如下:

RunLoop_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
102
103
104
105
106
107
108
109
110
/// 用DefaultMode启动
void CFRunLoopRun(void) {
CFRunLoopRunSpecific(CFRunLoopGetCurrent(), kCFRunLoopDefaultMode, 1.0e10, false);
}

/// 用指定的Mode启动,允许设置RunLoop超时时间
int CFRunLoopRunInMode(CFStringRef modeName, CFTimeInterval seconds, Boolean stopAfterHandle) {
return CFRunLoopRunSpecific(CFRunLoopGetCurrent(), modeName, seconds, returnAfterSourceHandled);
}

/// RunLoop的实现
int CFRunLoopRunSpecific(runloop, modeName, seconds, stopAfterHandle) {

/// 首先根据modeName找到对应mode
CFRunLoopModeRef currentMode = __CFRunLoopFindMode(runloop, modeName, false);
/// 如果mode里没有source/timer/observer, 直接返回。
if (__CFRunLoopModeIsEmpty(currentMode)) return;

/// 1. 通知 Observers: RunLoop 即将进入 loop。
__CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopEntry);

/// 内部函数,进入loop
__CFRunLoopRun(runloop, currentMode, seconds, returnAfterSourceHandled) {

Boolean sourceHandledThisLoop = NO;
int retVal = 0;
do {

/// 2. 通知 Observers: RunLoop 即将触发 Timer 回调。
__CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopBeforeTimers);
/// 3. 通知 Observers: RunLoop 即将触发 Source0 (非port) 回调。
__CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopBeforeSources);
/// 执行被加入的block
__CFRunLoopDoBlocks(runloop, currentMode);

/// 4. RunLoop 触发 Source0 (非port) 回调。
sourceHandledThisLoop = __CFRunLoopDoSources0(runloop, currentMode, stopAfterHandle);
/// 执行被加入的block
__CFRunLoopDoBlocks(runloop, currentMode);

/// 5. 如果有 Source1 (基于port) 处于 ready 状态,直接处理这个 Source1 然后跳转去处理消息。
if (__Source0DidDispatchPortLastTime) {
Boolean hasMsg = __CFRunLoopServiceMachPort(dispatchPort, &msg)
if (hasMsg) goto handle_msg;
}

/// 通知 Observers: RunLoop 的线程即将进入休眠(sleep)。
if (!sourceHandledThisLoop) {
__CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopBeforeWaiting);
}

/// 7. 调用 mach_msg 等待接受 mach_port 的消息。线程将进入休眠, 直到被下面某一个事件唤醒。
/// • 一个基于 port 的Source 的事件。
/// • 一个 Timer 到时间了
/// • RunLoop 自身的超时时间到了
/// • 被其他什么调用者手动唤醒
__CFRunLoopServiceMachPort(waitSet, &msg, sizeof(msg_buffer), &livePort) {
mach_msg(msg, MACH_RCV_MSG, port); // thread wait for receive msg
}

/// 8. 通知 Observers: RunLoop 的线程刚刚被唤醒了。
__CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopAfterWaiting);

/// 收到消息,处理消息。
handle_msg:

/// 9.1 如果一个 Timer 到时间了,触发这个Timer的回调。
if (msg_is_timer) {
__CFRunLoopDoTimers(runloop, currentMode, mach_absolute_time())
}

/// 9.2 如果有dispatch到main_queue的block,执行block。
else if (msg_is_dispatch) {
__CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__(msg);
}

/// 9.3 如果一个 Source1 (基于port) 发出事件了,处理这个事件
else {
CFRunLoopSourceRef source1 = __CFRunLoopModeFindSourceForMachPort(runloop, currentMode, livePort);
sourceHandledThisLoop = __CFRunLoopDoSource1(runloop, currentMode, source1, msg);
if (sourceHandledThisLoop) {
mach_msg(reply, MACH_SEND_MSG, reply);
}
}

/// 执行加入到Loop的block
__CFRunLoopDoBlocks(runloop, currentMode);


if (sourceHandledThisLoop && stopAfterHandle) {
/// 进入loop时参数说处理完事件就返回。
retVal = kCFRunLoopRunHandledSource;
} else if (timeout) {
/// 超出传入参数标记的超时时间了
retVal = kCFRunLoopRunTimedOut;
} else if (__CFRunLoopIsStopped(runloop)) {
/// 被外部调用者强制停止了
retVal = kCFRunLoopRunStopped;
} else if (__CFRunLoopModeIsEmpty(runloop, currentMode)) {
/// source/timer/observer一个都没有了
retVal = kCFRunLoopRunFinished;
}

/// 如果没超时,mode里没空,loop也没被停止,那继续loop。
} while (retVal == 0);
}

/// 10. 通知 Observers: RunLoop 即将退出。
__CFRunLoopDoObservers(rl, currentMode, kCFRunLoopExit);
}

可以看到,实际上 RunLoop 就是这样一个函数,其内部是一个 do-while 循环。当你调用 CFRunLoopRun() 时,线程就会一直停留在这个循环里;直到超时或被手动停止,该函数才会返回。

RunLoop 的底层实现


从上面代码可以看到,RunLoop 的核心是基于 mach port 的,其进入休眠时调用的函数是 mach_msg()。为了解释这个逻辑,下面稍微介绍一下 OSX/iOS 的系统架构。
RunLoop_3

苹果官方将整个系统大致划分为上述4个层次:

  1. 应用层包括用户能接触到的图形应用,例如 Spotlight、Aqua、SpringBoard 等。
  2. 应用框架层即开发人员接触到的 Cocoa 等框架。
  3. 核心框架层包括各种核心框架、OpenGL 等内容。
  4. Darwin 即操作系统的核心,包括系统内核、驱动、Shell 等内容,这一层是开源的,其所有源码都可以在 opensource.apple.com 里找到。

我们在深入看一下 Darwin 这个核心的架构:
RunLoop_4

其中,在硬件层上面的三个组成部分:Mach、BSD、IOKit (还包括一些上面没标注的内容),共同组成了 XNU 内核。

  1. XNU 内核的内环被称作 Mach,其作为一个微内核,仅提供了诸如处理器调度、IPC (进程间通信)等非常少量的基础服务。
  2. BSD 层可以看作围绕 Mach 层的一个外环,其提供了诸如进程管理、文件系统和网络等功能。
  3. IOKit 层是为设备驱动提供了一个面向对象(C++)的一个框架。

Mach 本身提供的 API 非常有限,而且苹果也不鼓励使用 Mach 的 API,但是这些API非常基础,如果没有这些API的话,其他任何工作都无法实施。在 Mach 中,所有的东西都是通过自己的对象实现的,进程、线程和虚拟内存都被称为”对象”。和其他架构不同, Mach 的对象间不能直接调用,只能通过消息传递的方式实现对象间的通信。”消息”是 Mach 中最基础的概念,消息在两个端口 (port) 之间传递,这就是 Mach 的 IPC (进程间通信) 的核心。

Mach 的消息定义是在 <mach/message.h> 头文件的,很简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
typedef struct {
mach_msg_header_t header;
mach_msg_body_t body;
} mach_msg_base_t;

typedef struct {
mach_msg_bits_t msgh_bits;
mach_msg_size_t msgh_size;
mach_port_t msgh_remote_port;
mach_port_t msgh_local_port;
mach_port_name_t msgh_voucher_port;
mach_msg_id_t msgh_id;
} mach_msg_header_t;

一条 Mach 消息实际上就是一个二进制数据包 (BLOB),其头部定义了当前端口 local_port 和目标端口 remote_port
发送和接受消息是通过同一个 API 进行的,其 option 标记了消息传递的方向:

1
2
3
4
5
6
7
8
mach_msg_return_t mach_msg(
mach_msg_header_t *msg,
mach_msg_option_t option,
mach_msg_size_t send_size,
mach_msg_size_t rcv_size,
mach_port_name_t rcv_name,
mach_msg_timeout_t timeout,
mach_port_name_t notify);

为了实现消息的发送和接收,mach_msg() 函数实际上是调用了一个 Mach 陷阱 (trap),即函数mach_msg_trap(),陷阱这个概念在 Mach 中等同于系统调用。当你在用户态调用 mach_msg_trap() 时会触发陷阱机制,切换到内核态;内核态中内核实现的 mach_msg() 函数会完成实际的工作,如下图:
RunLoop_5

这些概念可以参考维基百科: System_callTrap_(computing)

RunLoop 的核心就是一个 mach_msg() (见上面代码的第7步),RunLoop 调用这个函数去接收消息,如果没有别人发送 port 消息过来,内核会将线程置于等待状态。例如你在模拟器里跑起一个 iOS 的 App,然后在 App 静止时点击暂停,你会看到主线程调用栈是停留在 mach_msg_trap() 这个地方。

关于具体的如何利用 mach port 发送信息,可以看看 NSHipster 这一篇文章,或者这里的中文翻译 。

关于Mach的历史可以看看这篇很有趣的文章:Mac OS X 背后的故事(三)Mach 之父 Avie Tevanian

苹果用 RunLoop 实现的功能


首先我们可以看一下 App 启动后 RunLoop 的状态:

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
CFRunLoop {
current mode = kCFRunLoopDefaultMode
common modes = {
UITrackingRunLoopMode
kCFRunLoopDefaultMode
}

common mode items = {

// source0 (manual)
CFRunLoopSource {order =-1, {
callout = _UIApplicationHandleEventQueue}}
CFRunLoopSource {order =-1, {
callout = PurpleEventSignalCallback }}
CFRunLoopSource {order = 0, {
callout = FBSSerialQueueRunLoopSourceHandler}}

// source1 (mach port)
CFRunLoopSource {order = 0, {port = 17923}}
CFRunLoopSource {order = 0, {port = 12039}}
CFRunLoopSource {order = 0, {port = 16647}}
CFRunLoopSource {order =-1, {
callout = PurpleEventCallback}}
CFRunLoopSource {order = 0, {port = 2407,
callout = _ZL20notify_port_callbackP12__CFMachPortPvlS1_}}
CFRunLoopSource {order = 0, {port = 1c03,
callout = __IOHIDEventSystemClientAvailabilityCallback}}
CFRunLoopSource {order = 0, {port = 1b03,
callout = __IOHIDEventSystemClientQueueCallback}}
CFRunLoopSource {order = 1, {port = 1903,
callout = __IOMIGMachPortPortCallback}}

// Ovserver
CFRunLoopObserver {order = -2147483647, activities = 0x1, // Entry
callout = _wrapRunLoopWithAutoreleasePoolHandler}
CFRunLoopObserver {order = 0, activities = 0x20, // BeforeWaiting
callout = _UIGestureRecognizerUpdateObserver}
CFRunLoopObserver {order = 1999000, activities = 0xa0, // BeforeWaiting | Exit
callout = _afterCACommitHandler}
CFRunLoopObserver {order = 2000000, activities = 0xa0, // BeforeWaiting | Exit
callout = _ZN2CA11Transaction17observer_callbackEP19__CFRunLoopObservermPv}
CFRunLoopObserver {order = 2147483647, activities = 0xa0, // BeforeWaiting | Exit
callout = _wrapRunLoopWithAutoreleasePoolHandler}

// Timer
CFRunLoopTimer {firing = No, interval = 3.1536e+09, tolerance = 0,
next fire date = 453098071 (-4421.76019 @ 96223387169499),
callout = _ZN2CAL14timer_callbackEP16__CFRunLoopTimerPv (QuartzCore.framework)}
},

modes = {
CFRunLoopMode {
sources0 = { /* same as 'common mode items' */ },
sources1 = { /* same as 'common mode items' */ },
observers = { /* same as 'common mode items' */ },
timers = { /* same as 'common mode items' */ },
},

CFRunLoopMode {
sources0 = { /* same as 'common mode items' */ },
sources1 = { /* same as 'common mode items' */ },
observers = { /* same as 'common mode items' */ },
timers = { /* same as 'common mode items' */ },
},

CFRunLoopMode {
sources0 = {
CFRunLoopSource {order = 0, {
callout = FBSSerialQueueRunLoopSourceHandler}}
},
sources1 = (null),
observers = {
CFRunLoopObserver >{activities = 0xa0, order = 2000000,
callout = _ZN2CA11Transaction17observer_callbackEP19__CFRunLoopObservermPv}
)},
timers = (null),
},

CFRunLoopMode {
sources0 = {
CFRunLoopSource {order = -1, {
callout = PurpleEventSignalCallback}}
},
sources1 = {
CFRunLoopSource {order = -1, {
callout = PurpleEventCallback}}
},
observers = (null),
timers = (null),
},

CFRunLoopMode {
sources0 = (null),
sources1 = (null),
observers = (null),
timers = (null),
}
}
}

可以看到,系统默认注册了5个Mode:

  1. kCFRunLoopDefaultMode: App的默认 Mode,通常主线程是在这个 Mode 下运行的。
  2. UITrackingRunLoopMode: 界面跟踪 Mode,用于 ScrollView 追踪触摸滑动,保证界面滑动时不受其他 Mode 影响。
  3. UIInitializationRunLoopMode: 在刚启动 App 时第进入的第一个 Mode,启动完成后就不再使用。
  4. GSEventReceiveRunLoopMode: 接受系统事件的内部 Mode,通常用不到。
  5. kCFRunLoopCommonModes: 这是一个占位的 Mode,没有实际作用。

你可以在这里看到更多的苹果内部的 Mode,但那些 Mode 在开发中就很难遇到了。

当 RunLoop 进行回调时,一般都是通过一个很长的函数调用出去 (call out), 当你在你的代码中下断点调试时,通常能在调用栈上看到这些函数。下面是这几个函数的整理版本,如果你在调用栈中看到这些长函数名,在这里查找一下就能定位到具体的调用地点了:

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
{
/// 1. 通知Observers,即将进入RunLoop
/// 此处有Observer会创建AutoreleasePool: _objc_autoreleasePoolPush();
__CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__(kCFRunLoopEntry);
do {

/// 2. 通知 Observers: 即将触发 Timer 回调。
__CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__(kCFRunLoopBeforeTimers);
/// 3. 通知 Observers: 即将触发 Source (非基于port的,Source0) 回调。
__CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__(kCFRunLoopBeforeSources);
__CFRUNLOOP_IS_CALLING_OUT_TO_A_BLOCK__(block);

/// 4. 触发 Source0 (非基于port的) 回调。
__CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__(source0);
__CFRUNLOOP_IS_CALLING_OUT_TO_A_BLOCK__(block);

/// 6. 通知Observers,即将进入休眠
/// 此处有Observer释放并新建AutoreleasePool: _objc_autoreleasePoolPop(); _objc_autoreleasePoolPush();
__CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__(kCFRunLoopBeforeWaiting);

/// 7. sleep to wait msg.
mach_msg() -> mach_msg_trap();


/// 8. 通知Observers,线程被唤醒
__CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__(kCFRunLoopAfterWaiting);

/// 9. 如果是被Timer唤醒的,回调Timer
__CFRUNLOOP_IS_CALLING_OUT_TO_A_TIMER_CALLBACK_FUNCTION__(timer);

/// 9. 如果是被dispatch唤醒的,执行所有调用 dispatch_async 等方法放入main queue 的 block
__CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__(dispatched_block);

/// 9. 如果如果Runloop是被 Source1 (基于port的) 的事件唤醒了,处理这个事件
__CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE1_PERFORM_FUNCTION__(source1);


} while (...);

/// 10. 通知Observers,即将退出RunLoop
/// 此处有Observer释放AutoreleasePool: _objc_autoreleasePoolPop();
__CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__(kCFRunLoopExit);
}

AutoreleasePool

App启动后,苹果在主线程 RunLoop 里注册了两个 Observer,其回调都是 _wrapRunLoopWithAutoreleasePoolHandler()

  1. 第一个 Observer 监视的事件是 Entry(即将进入Loop),其回调内会调用 _objc_autoreleasePoolPush() 创建自动释放池。其 order 是-2147483647,优先级最高,保证创建释放池发生在其他所有回调之前。

  2. 第二个 Observer 监视了两个事件:

    1. BeforeWaiting(准备进入休眠) 时调用_objc_autoreleasePoolPop()_objc_autoreleasePoolPush() 释放旧的池并创建新池;
    2. Exit(即将退出Loop) 时调用 _objc_autoreleasePoolPop() 来释放自动释放池。这个 Observerorder 是 2147483647,优先级最低,保证其释放池子发生在其他所有回调之后。

在主线程执行的代码,通常是写在诸如事件回调、Timer回调内的。这些回调会被 RunLoop 创建好的 AutoreleasePool 环绕着,所以不会出现内存泄漏,开发者也不必显示创建 Pool 了。

事件响应

苹果注册了一个 Source1 (基于 mach port 的) 用来接收系统事件,其回调函数为 __IOHIDEventSystemClientQueueCallback()

当一个硬件事件(触摸/锁屏/摇晃等)发生后,首先由 IOKit.framework 生成一个 IOHIDEvent 事件并由 SpringBoard 接收。这个过程的详细情况可以参考这里。SpringBoard 只接收按键(锁屏/静音等),触摸,加速,接近传感器等几种 Event,随后用 mach port 转发给需要的App进程。随后苹果注册的那个 Source1 就会触发回调,并调用 _UIApplicationHandleEventQueue() 进行应用内部的分发。

_UIApplicationHandleEventQueue() 会把 IOHIDEvent 处理并包装成 UIEvent 进行处理或分发,其中包括识别 UIGesture/处理屏幕旋转/发送给 UIWindow 等。通常事件比如 UIButton 点击、touchesBegin/Move/End/Cancel 事件都是在这个回调中完成的。

手势识别

当上面的 _UIApplicationHandleEventQueue() 识别了一个手势时,其首先会调用 Cancel 将当前的 touchesBegin/Move/End 系列回调打断。随后系统将对应的 UIGestureRecognizer 标记为待处理

苹果注册了一个 Observer 监测 BeforeWaiting (Loop即将进入休眠) 事件,这个Observer的回调函数是 _UIGestureRecognizerUpdateObserver(),其内部会获取所有刚被标记为待处理GestureRecognizer,并执行GestureRecognizer的回调。

当有 UIGestureRecognizer 的变化(创建/销毁/状态改变)时,这个回调都会进行相应处理。

界面更新

当在操作 UI 时,比如改变了 Frame、更新了 UIView/CALayer 的层次时,或者手动调用了 UIView/CALayer 的 setNeedsLayout/setNeedsDisplay方法后,这个 UIView/CALayer 就被标记为待处理,并被提交到一个全局的容器去。

苹果注册了一个 Observer 监听 BeforeWaiting(即将进入休眠) 和 Exit (即将退出Loop) 事件,回调去执行一个很长的函数:
_ZN2CA11Transaction17observer_callbackEP19__CFRunLoopObservermPv()。这个函数里会遍历所有待处理的 UIView/CAlayer 以执行实际的绘制和调整,并更新 UI 界面。

这个函数内部的调用栈大概是这样的:

1
2
3
4
5
6
7
8
9
10
11
_ZN2CA11Transaction17observer_callbackEP19__CFRunLoopObservermPv()
QuartzCore:CA::Transaction::observer_callback:
CA::Transaction::commit();
CA::Context::commit_transaction();
CA::Layer::layout_and_display_if_needed();
CA::Layer::layout_if_needed();
[CALayer layoutSublayers];
[UIView layoutSubviews];
CA::Layer::display_if_needed();
[CALayer display];
[UIView drawRect];

定时器

NSTimer 其实就是 CFRunLoopTimerRef,他们之间是 toll-free bridged 的。一个 NSTimer 注册到 RunLoop 后,RunLoop 会为其重复的时间点注册好事件。例如 10:00, 10:10, 10:20 这几个时间点。RunLoop为了节省资源,并不会在非常准确的时间点回调这个TimerTimer 有个属性叫做 Tolerance (宽容度),标示了当时间点到后,容许有多少最大误差。

如果某个时间点被错过了,例如执行了一个很长的任务,则那个时间点的回调也会跳过去,不会延后执行。就比如等公交,如果 10:10 时我忙着玩手机错过了那个点的公交,那我只能等 10:20 这一趟了。

CADisplayLink 是一个和屏幕刷新率一致的定时器(但实际实现原理更复杂,和 NSTimer 并不一样,其内部实际是操作了一个 Source)。如果在两次屏幕刷新之间执行了一个长任务,那其中就会有一帧被跳过去(和 NSTimer 相似),造成界面卡顿的感觉。在快速滑动TableView时,即使一帧的卡顿也会让用户有所察觉。Facebook 开源的 AsyncDisplayLink 就是为了解决界面卡顿的问题,其内部也用到了 RunLoop,这个稍后我会再单独写一页博客来分析。

PerformSelecter

当调用 NSObject 的 performSelecter:afterDelay: 后,实际上其内部会创建一个 Timer 并添加到当前线程的 RunLoop 中。所以如果当前线程没有 RunLoop,则这个方法会失效

当调用 performSelector:onThread: 时,实际上其会创建一个 Timer 加到对应的线程去,同样的,如果对应线程没有 RunLoop 该方法也会失效。

关于GCD

实际上 RunLoop 底层也会用到 GCD 的东西,比如 RunLoop 是用 dispatch_source_t 实现的 Timer(评论中有人提醒,NSTimer 是用了 XNU 内核的 mk_timer,我也仔细调试了一下,发现 NSTimer 确实是由 mk_timer 驱动,而非 GCD 驱动的)。但同时 GCD 提供的某些接口也用到了 RunLoop, 例如 dispatch_async()

当调用 dispatch_async(dispatch_get_main_queue(), block) 时,libDispatch 会向主线程的 RunLoop 发送消息,RunLoop会被唤醒,并从消息中取得这个 block,并在回调 __CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__() 里执行这个 block。但这个逻辑仅限于 dispatch 到主线程,dispatch 到其他线程仍然是由 libDispatch 处理的。

关于网络请求

iOS 中,关于网络请求的接口自下至上有如下几层:

1
2
3
4
CFSocket
CFNetwork ->ASIHttpRequest
NSURLConnection ->AFNetworking
NSURLSession ->AFNetworking2, Alamofire

CFSocket 是最底层的接口,只负责 socket 通信。
CFNetwork 是基于 CFSocket 等接口的上层封装,ASIHttpRequest 工作于这一层。
NSURLConnection 是基于 CFNetwork 的更高层的封装,提供面向对象的接口,AFNetworking 工作于这一层。
NSURLSession 是 iOS7 中新增的接口,表面上是和 NSURLConnection 并列的,但底层仍然用到了 NSURLConnection 的部分功能 (比如 com.apple.NSURLConnectionLoader 线程),AFNetworking2Alamofire 工作于这一层。

下面主要介绍下 NSURLConnection 的工作过程。

通常使用 NSURLConnection 时,你会传入一个 Delegate,当调用了 [connection start] 后,这个 Delegate 就会不停收到事件回调。实际上,start 这个函数的内部会会获取 CurrentRunLoop,然后在其中的 DefaultMode 添加了4个 Source0 (即需要手动触发的Source)。CFMultiplexerSource 是负责各种 Delegate 回调的,CFHTTPCookieStorage 是处理各种 Cookie 的。

当开始网络传输时,我们可以看到 NSURLConnection 创建了两个新线程:com.apple.NSURLConnectionLoadercom.apple.CFSocket.private。其中 CFSocket 线程是处理底层 socket 连接的。NSURLConnectionLoader 这个线程内部会使用 RunLoop 来接收底层 socket 的事件,并通过之前添加的 Source0 通知到上层的 Delegate

RunLoop_network

NSURLConnectionLoader 中的 RunLoop 通过一些基于 mach portSource 接收来自底层 CFSocket 的通知。当收到通知后,其会在合适的时机向 CFMultiplexerSourceSource0 发送通知,同时唤醒 Delegate 线程的 RunLoop 来让其处理这些通知。CFMultiplexerSource 会在 Delegate 线程的 RunLoopDelegate 执行实际的回调。

RunLoop 的实际应用举例


AFNetworking

AFURLConnectionOperation 这个类是基于 NSURLConnection 构建的,其希望能在后台线程接收 Delegate 回调。为此 AFNetworking 单独创建了一个线程,并在这个线程中启动了一个 RunLoop

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
+ (void)networkRequestThreadEntryPoint:(id)__unused object {
@autoreleasepool {
[[NSThread currentThread] setName:@"AFNetworking"];
NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
[runLoop addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode];
[runLoop run];
}
}

+ (NSThread *)networkRequestThread {
static NSThread *_networkRequestThread = nil;
static dispatch_once_t oncePredicate;
dispatch_once(&oncePredicate, ^{
_networkRequestThread = [[NSThread alloc] initWithTarget:self selector:@selector(networkRequestThreadEntryPoint:) object:nil];
[_networkRequestThread start];
});
return _networkRequestThread;
}

RunLoop 启动前内部必须要有至少一个 Timer/Observer/Source,所以 AFNetworking 在 [runLoop run] 之前先创建了一个新的 NSMachPort 添加进去了。通常情况下,调用者需要持有这个 NSMachPort (mach_port) 并在外部线程通过这个 port 发送消息到 loop 内;但此处添加 port 只是为了让 RunLoop 不至于退出,并没有用于实际的发送消息。

1
2
3
4
5
6
7
8
9
10
11
- (void)start {
[self.lock lock];
if ([self isCancelled]) {
[self performSelector:@selector(cancelConnection) onThread:[[self class] networkRequestThread] withObject:nil waitUntilDone:NO modes:[self.runLoopModes allObjects]];
} else if ([self isReady]) {
self.state = AFOperationExecutingState;
[self performSelector:@selector(operationDidStart) onThread:[[self class] networkRequestThread] withObject:nil waitUntilDone:NO modes:[self.runLoopModes allObjects]];
}
[self.lock unlock];
}

当需要这个后台线程执行任务时,AFNetworking 通过调用 [NSObject performSelector:onThread:..] 将这个任务扔到了后台线程的 RunLoop 中。

AsyncDisplayKit

AsyncDisplayKit 是 Facebook 推出的用于保持界面流畅性的框架,其原理大致如下:

  1. UI 线程中一旦出现繁重的任务就会导致界面卡顿,这类任务通常分为3类:排版,绘制,UI对象操作。
    1. 排版通常包括计算视图大小、计算文本高度、重新计算子式图的排版等操作。
    2. 绘制一般有文本绘制 (例如 CoreText)、图片绘制 (例如预先解压)、元素绘制 (Quartz)等操作。
    3. UI对象操作通常包括 UIView/CALayer 等 UI 对象的创建、设置属性和销毁。
  2. 其中前两类操作可以通过各种方法扔到后台线程执行,而最后一类操作只能在主线程完成,并且有时后面的操作需要依赖前面操作的结果 (例如TextView创建时可能需要提前计算出文本的大小)。ASDK 所做的,就是尽量将能放入后台的任务放入后台,不能的则尽量推迟 (例如视图的创建、属性的调整)。
  3. 为此,ASDK 创建了一个名为 ASDisplayNode 的对象,并在内部封装了 UIView/CALayer,它具有和 UIView/CALayer 相似的属性,例如 frame、backgroundColor等。所有这些属性都可以在后台线程更改,开发者可以只通过 Node 来操作其内部的 UIView/CALayer,这样就可以将排版和绘制放入了后台线程。但是无论怎么操作,这些属性总需要在某个时刻同步到主线程的 UIView/CALayer 去。
  4. ASDK 仿照 QuartzCore/UIKit 框架的模式,实现了一套类似的界面更新的机制:即在主线程的 RunLoop 中添加一个 Observer,监听了 kCFRunLoopBeforeWaitingkCFRunLoopExit 事件,在收到回调时,遍历所有之前放入队列的待处理的任务,然后一一执行。
    具体的代码可以看这里:_ASAsyncTransactionGroup

[转载] C++常见避坑指南

转载 C++常见避坑指南

原文链接

C++ 从入门到放弃?本文主要总结了在 C++ 开发或 review 过程中常见易出错点做了归纳总结,希望借此能增进大家对 C++ 的了解,减少编程出错,提升工作效率,也可以作为C++开发的避坑攻略。

空指针调用成员函数会crash??

当调用一个空指针所指向的类的成员函数时,大多数人的反应都是程序会crash。空指针并不指向任何有效的内存地址,所以在调用成员函数时会尝试访问一个不存在的内存地址,从而导致程序崩溃。

事实上有点出乎意料,先来看段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
 class MyClass {
public:
static void Test_Func1() {
cout << "Handle Test_Func1!" << endl;
}
void Test_Func2() {
cout << "Handle Test_Func2!" << endl;
}
void Test_Func3() {
cout << "Handle Test_Func3! value:" << value << endl;
}
virtual void Test_Func4() {
cout << "Handle Test_Func4!" << endl;
}
int value = 0;
};

int main() {
MyClass* ptr = nullptr;
ptr->Test_Func1(); // ok, print Handle Test_Func1!
ptr->Test_Func2(); // ok, print Handle Test_Func2!
ptr->Test_Func3(); // crash
ptr->Test_Func4(); // crash return 0;
}

上面例子中,空指针对Test_Func1和Test_Func2的调用正常,对Test_Func3和Test_Func4的调用会crash。可能很多人反应都会crash,实际上并没有,这是为啥?

类的成员函数并不与具体对象绑定,所有的对象共用同一份成员函数体,当程序被编译后,成员函数的地址即已确定,这份共有的成员函数体之所以能够把不同对象的数据区分开来,靠的是隐式传递给成员函数的this指针,成员函数中对成员变量的访问都是转化成”this->数据成员”的方式。因此,从这一角度说,成员函数与普通函数一样,只是多了this指针。而类的静态成员函数只能访问静态成员变量,不能访问非静态成员变量,所以静态成员函数不需要this指针作为隐式参数。

因此,Test_Func1是静态成员函数,不需要this指针,所以即使ptr是空指针,也不影响对Test_Fun1的正常调用。Test_Fun2虽然需要传递隐式指针,但是函数体中并没有使用到这个隐式指针,所以ptr为空也不影响对Test_Fun2的正常调用。Test_Fun3就不一样了,因为函数中使用到了非静态的成员变量,对num的调用被转化成this->num,也就是ptr->num,而ptr是空指针,因此会crash。Test_Fun4是虚函数,有虚函数的类会有一个成员变量,即虚表指针,当调用虚函数时,会使用虚表指针,对虚表指针的使用也是通过隐式指针使用的,因此Test_Fun4的调用也会crash。

同理,以下std::shared_ptr的调用也是如此,日常开发需要注意,记得加上判空。

1
2
3
4
5
std::shared_ptr<UrlHandler> url_handler;
...
if(url_handler->IsUrlNeedHandle(data)) {
url_handler->HandleUrl(param);
}

字符串相关

字符串查找

对字符串进行处理是一个很常见的业务场景,其中字符串查找也是非常常见的,但是用的不好也是会存在各种坑。常见的字符串查找方法有:std::string::findstd::string::find_first_ofstd::string::find_first_not_ofstd::string::find_last_of,各位C++ Engineer都能熟练使用了吗?先来段代码瞧瞧:

1
2
3
4
5
6
7
8
9
10
11
12
 bool IsBlacklistDllFromSrv(const std::string& dll_name) {
try {
std::string target_str = dll_name;
std::transform(target_str.begin(), target_str.end(), target_str.begin(), ::tolower);
if (dll_blacklist_from_srv.find(target_str) != std::string::npos) {
return true;
}
}
catch (...) {
}
return false;
}

上面这段代码,看下来没啥问题的样子。但是仔细看下来,就会发现字符串比对这里逻辑不够严谨,存在很大的漏洞。std::string::find只是用来在字符串中查找指定的子字符串,只要包含该子串就符合,如果dll_blacklist_from_srv = "abcd.dll;hhhh.dll;test.dll" 是这样的字符串,传入d.dllhh.dlldll;test.dll也会命中逻辑,明显是不太符合预期的。

这里顺带回顾下C++ std::string常见的字符串查找的方法:

std::string::find
用于在字符串中查找指定的子字符串。如果找到了子串,则返回子串的起始位置,否则返回std::string::npos。用于各种字符串操作,例如判断子字符串是否存在、获取子字符串的位置等。通过结合其他成员函数和算法,可以实现更复杂的字符串处理逻辑。

std::string::find_first_of
用于查找字符串中第一个与指定字符集合中的任意字符匹配的字符,并返回其位置。可用来检查字符串中是否包含指定的某些字符或者查找字符串中第一个出现的特定字符

std::string::find_first_not_of
用于查找字符串中第一个不与指定字符集合中的任何字符匹配的字符,并返回其位置。

std::string::find_last_of
用于查找字符串中最后一个与指定字符集合中的任意字符匹配的字符,并返回其位置。可以用来检查字符串中是否包含指定的某些字符,或者查找字符串中最后一个出现的特定字符

std::string::find_last_not_of
用于查找字符串中最后一个不与指定字符集合中的任何字符匹配的字符,并返回其位置。

除了以上几个方法外,还有查找满足指定条件的元素std::find_if

std::find_if 是 C++ 标准库中的一个算法函数,用于在指定范围内查找第一个满足指定条件的元素,并返回其迭代器。需要注意的是,使用 std::find_if 函数时需要提供一个可调用对象(例如 lambda 表达式或函数对象),用于指定查找条件。

1
2
3
4
5
std::vector<int> vec = {1, 2, 3, 4, 5};
auto it = std::find_if(vec.begin(), vec.end(), [](int x) { return x % 2 == 0; });
if (it != vec.end()) {
std::cout << "Found even number: " << *it << std::endl;
}

此外,在业务开发有时候也会遇到需要C++ boost库支持的starts_withends_with。如果用C++标准库来实现,常规编写方法可如下:

1
2
3
4
5
6
7
8
9
10
bool starts_with(const std::string& str, const std::string& prefix) {
return str.compare(0, prefix.length(), prefix) == 0;
}
bool ends_with(const std::string& str, const std::string& suffix) {
if (str.length() < suffix.length()) {
return false;
} else {
return str.compare(str.length() - suffix.length(), suffix.length(), suffix) == 0;
}
}

以上代码中,starts_with 函数和 ends_with 函数分别用于检查字符串的前缀和后缀。两个函数内部都使用了 std::string::compare 方法来比较字符串的子串和指定的前缀或后缀是否相等。如果相等,则说明字符串满足条件,返回 true;否则返回 false。

std::string与std::wstring转换

对字符串进行处理是一个很常见的业务场景,尤其是C++客户端开发,我们经常需要在窄字符串std::string与宽字符串std::wstring之间进行转换,有时候一不小心就会出现各种中文乱码。还有就是一提到窄字符串与宽字符串互转以及时不时出现的中文乱码,很多人就犯晕。

在 C++ 中,std::stringstd::wstring之间的转换涉及到字符编码的转换。如果在转换过程中出现乱码,可能是由于字符编码不匹配导致的。要正确地进行std::stringstd::wstring之间的转换,需要确保源字符串的字符编码和目标字符串的字符编码一致,避免C++中的字符串处理乱码,可以使用Unicode编码(如UTF-8、UTF-16或UTF-32)来存储和处理字符串。

我们想要处理或解析一些Unicode数据,例如从Windows REG文件读取,使用std::wstring变量更能方便的处理它们。例如:std::wstring ws=L“中国a”(6个八位字节内存:0x4E2D 0x56FD 0x0061),我们可以使用ws[0]获取字符”中”,使用ws[1]获取字符”国”,使用ws[2]获取字符 ‘a’ 等,这个时候如果使用std::string,ws[0]拿出来的就是乱码。

此外还受代码页编码的影响(比如VS可以通过文件->高级保存选项->编码 来更改当前代码页的编码)。

下面是一些示例代码,演示了如何进行正确的转换,针对Windows平台,官方提供了相应的系统Api(MultiByteToWideChar):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
std::wstring Utf8ToUnicode(const std::string& str) {
int len = str.length();
if (0 == len)
return L"";
int nLength = MultiByteToWideChar(CP_UTF8, 0, str.c_str(), len, 0, 0);
std::wstring buf(nLength + 1, L'\0');
MultiByteToWideChar(CP_UTF8, 0, str.c_str(), len, &buf[0], nLength);
buf.resize(wcslen(buf.c_str()));
return buf;
}
std::string UnicodeToUtf8(const std::wstring& wstr) {
if (wstr.empty()) {
return std::string();
}
int size_needed = WideCharToMultiByte(CP_UTF8, 0, &wstr[0], static_cast<int>(wstr.size()), nullptr, 0, nullptr, nullptr);
std::string str_to(size_needed, 0);
WideCharToMultiByte(CP_UTF8, 0, &wstr[0], static_cast<int>(wstr.size()), &str_to[0], size_needed, nullptr, nullptr);
return str_to;
}

如果使用C++标准库来实现,常规写法可以参考下面:

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
#include <iostream>
#include <string>
#include <locale>
#include <codecvt>
// 从窄字符串到宽字符串的转换
std::wstring narrowToWide(const std::string& narrowStr) {
try {
std::wstring_convert<std::codecvt_utf8<wchar_t>> converter;
return converter.from_bytes(narrowStr);
} catch (...) { // 如果传进来的字符串不是utf8编码的,这里会抛出std::range_error异常
return {};
}
}
// 从宽字符串到窄字符串的转换
std::string wideToNarrow(const std::wstring& wideStr) {
try {
std::wstring_convert<std::codecvt_utf8<wchar_t>> converter;
return converter.to_bytes(wideStr);
} catch (...) {
return {};
}
}
//utf8字符串转成string
std::string utf8ToString(const char8_t* str) {
std::wstring_convert<std::codecvt_utf8_utf16<char16_t>, char16_t> convert;
std::u16string u16str = convert.from_bytes(
reinterpret_cast<const char*>(str),
reinterpret_cast<const char*>(str + std::char_traits<char8_t>::length(str)));
return std::wstring_convert<std::codecvt_utf8_utf16<char16_t>, char16_t>{}.to_bytes(u16str);
}
int main(){
{
std::wstring wideStr = L"Hello, 你好!";
std::string narrowStr = wideToNarrow(wideStr);
std::wstring convertedWideStr = narrowToWide(narrowStr);
} {
//std::string narrowStr = "Hello, 你好!"; (1)
std::string narrowStr = utf8ToString(u8"Hello, 你好!"); //(2)
std::wstring wideStr = narrowToWide(narrowStr);
std::string convertedNarrowStr = wideToNarrow(wideStr);
}
return 0;
}

(1)首先std::string不理解编码,在CPP官方手册里面也解释了,std::string处理字节的方式与所使用的编码无关,如果用于处理多字节或可变长度字符的序列(例如 UTF-8),则此类的所有成员以及它的迭代器仍然以字节(而不是实际的编码字符)为单位进行操作,如果用来处理包含中文的字符串就可能出现乱码。这里直接将包含中文的字符串赋值给std::string,无法保证是UTF8编码,进行转换时会提示std::range_error异常;此外,std::wstring是会理解编码的,其中的字符串通常使用 UTF-16 或 UTF-32 编码,这取决于操作系统和编译器的实现。

(2)这里由于使用u8””构造了UTF8编码字符串,但是不能直接用来构造std::string,所以进行转了下utf8ToString;

全局静态对象

大家有没有在工程代码中发现有下面这种写法,将常量字符串声明为静态全局的。

1
2
3
static const std::string kVal="hahahhaha";

static const std::wstring kxxConfigVal="hahahhaha";
  • 优点:
    • 可读性好:使用有意义的变量名,可以清晰地表达变量的含义和用途,提高了代码的可读性。
    • 安全性高:由于使用了 const 关键字,这个字符串变量是不可修改的,可以避免意外的修改和安全问题。
    • 生命周期长:静态变量的生命周期从程序启动到结束,不受函数的调用和返回影响。
  • 缺点:
    • 构造开销:静态变量的初始化发生在程序启动时也就是执行main()之前,会增加程序启动的时间和资源消耗。大量的这种静态全局对象,会拖慢程序启动速度
    • 静态变量共享:静态变量在整个程序中只有一份实例,可能会导致全局状态共享和难以调试的问题。
    • 此外,静态变量的初始化顺序可能会受到编译单元(源文件)中其他静态变量初始化顺序的影响,因此在跨编译单元的情况下,静态变量的初始化顺序可能是不确定的。

在实际编程中,还是不太建议使用全局静态对象,建议的写法:

要声明全局的常量字符串,可以使用 const 关键字和 extern 关键字的组合:

1
2
3
4
5
6
7
8
9
10
11
// constants.h
extern const char* GLOBAL_STRING;
// constants.cpp
\#include "constants.h"
const char* GLOBAL_STRING = "Hello, world!";
constexpr char* kVal="hahhahah";


使用 constexpr 关键字来声明全局的常量字符串:
// constants.h
constexpr const char* GLOBAL_STRING = "Hello, world!";

迭代器删除

在处理缓存时,容器元素的增删查改是很常见的,通过迭代器去删除容器(vector/map/set/unordered_map/list)元素也是常有的,但这其中使用不当也会存在很多坑。

1
2
3
4
5
std::vector<int> numbers = { 88, 101, 56, 203, 72, 135 };
auto it = std::find_if(numbers.begin(), numbers.end(), [](int num) {
return num > 100 && num % 2 != 0;
});
vec.erase(it);

上面代码,查找std::vector中大于 100 并且为奇数的整数并将其删除。std::find_if 将从容器的开头开始查找,直到找到满足条件的元素或者遍历完整个容器,并返回迭代器it,然后去删除该元素。但是这里没有判断it为空的情况,直接就erase了,如果erase一个空的迭代器会引发crash。很多新手程序员会犯这样的错误,随时判空是个不错的习惯。

删除元素不得不讲下std::removestd::remove_if,用于从容器中移除指定的元素, 函数会将符合条件的元素移动到容器的末尾,并返回指向新的末尾位置之后的迭代器,最后使用容器的erase来擦除从新的末尾位置开始的元素。

1
2
3
4
5
std::vector<std::string> vecs = { "A", "", "B", "", "C", "hhhhh", "D" };
vecs.erase(std::remove(vecs.begin(), vecs.end(), ""), vecs.end());

// 移除所有偶数元素
vec.erase(std::remove_if(vec.begin(), vec.end(), [](int x) { return x % 2 == 0; }), vec.end());

这里的erase不用判空,其内部实现已经有判空处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
_CONSTEXPR20 iterator erase(const_iterator _First, const_iterator _Last) noexcept(
is_nothrow_move_assignable_v<value_type>) /* strengthened */ {
const pointer _Firstptr = _First._Ptr;
const pointer _Lastptr = _Last._Ptr;
auto& _My_data = _Mypair._Myval2;
pointer& _Mylast = _My_data._Mylast;
// ....
if (_Firstptr != _Lastptr) { // something to do, invalidate iterators
_Orphan_range(_Firstptr, _Mylast);
const pointer _Newlast = _Move_unchecked(_Lastptr, _Mylast, _Firstptr);
_Destroy_range(_Newlast, _Mylast, _Getal());
_Mylast = _Newlast;
}
return iterator(_Firstptr, _STD addressof(_My_data));
}

此外,STL容器的删除也要小心迭代器失效,先来看个vector、list、map删除的例子:

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
// vector、list、map遍历并删除偶数元素
std::vector<int> elements = { 1, 2, 3, 4, 5 };
for (auto it = elements.begin(); it != elements.end();) {
if (*it % 2 == 0) {
elements.erase(it++);
} else {
it++;
}
}
// Error
std::list<int> cont{ 88, 101, 56, 203, 72, 135 };
for (auto it = cont.begin(); it != cont.end(); ) {
if (*it % 2 == 0) {
cont.erase(it++);
} else {
it++;
}
}
// Ok
std::map<int, std::string> myMap = { {1, "one"}, {2, "two"}, {3, "three"}, {4, "four"}, {5, "five"} };
// 遍历并删除键值对,删除键为偶数的元素
for (auto it = myMap.begin(); it != myMap.end(); ) {
if (it->first % 2 == 0) {
myMap.erase(it++);
} else {
it++;
}
}
// Ok

上面几类容器同样的遍历删除元素,只有vector报错crash了,maplist都能正常运行。其实vector调用erase()方法后,当前位置到容器末尾元素的所有迭代器全部失效了,以至于不能再使用。

迭代器的失效问题:对容器的操作影响了元素的存放位置,称为迭代器失效。迭代器失效的情况:

  • 当容器调用erase()方法后,当前位置到容器末尾元素的所有迭代器全部失效。
  • 当容器调用insert()方法后,当前位置到容器末尾元素的所有迭代器全部失效。
  • 如果容器扩容,在其他地方重新又开辟了一块内存,原来容器底层的内存上所保存的迭代器全都失效。

迭代器失效有三种情况,由于底层的存储数据结构,分三种情况:

序列式迭代器失效,序列式容器(std::vectorstd::deque),其对应的数据结构分配在连续的内存中,对其中的迭代器进行inserterase操作都会使得删除点和插入点之后的元素挪位置,进而导致插入点和删除掉之后的迭代器全部失效。可以利用erase迭代器接口返回的是下一个有效的迭代器。

链表式迭代器失效,链表式容器(std::list)使用链表进行数据存储,插入或者删除只会对当前的节点造成影响,不会影响其他的迭代器。可以利用erase迭代器接口返回的是下一个有效的迭代器,或者将当前的迭代器指向下一个erase(iter++)

关联式迭代器失效,关联式容器,如map, set,multimap,multiset等,使用红黑树进行数据存储,删除当前的迭代器,仅会使当前的迭代器失效。erase迭代器的返回值为 void(C++11之前),可以采用erase(iter++)的方式进行删除。值得一提的是,在最新的C++11标准中,已经新增了一个map::erase函数执行后会返回下一个元素的iterator,因此可以使用erase的返回值获取下一个有效的迭代器。

在实现上有两种模板,其一是通过 erase 获得下一个有效的 iterator,使用于序列式迭代器和链表式迭代器(C++11开始关联式迭代器也可以使用)

1
2
3
4
5
6
7
for (auto it = elements.begin(); it != elements.end(); ) {
if (ShouldDelete(*it)) {
it = elements.erase(it); // erase删除元素,返回下一个迭代器
} else {
it++;
}
}

其二是,递增当前迭代器,适用于链表式迭代器和关联式迭代器。

1
2
3
4
5
6
7
for (auto it = elements.begin(); it != elements.end(); ) {
if (ShouldDelete(*it)) {
elements.erase(it++);
} else {
it++;
}
}

对象拷贝

在众多编程语言中C++的优势之一便是其高性能,可是开发者代码写得不好(比如:很多不必要的对象拷贝),直接会影响到代码性能,接下来就讲几个常见的会引起无意义拷贝的场景。

for循环:

1
2
3
4
5
6
7
std::vector<std::string> vec;
for(std::string s: vec) {
}
// or
for(auto s: vec) {
}

这里每个string都会被拷贝一次,为避免无意义拷贝可以将其改成:

1
for(const auto& s: vec) 或者 for (const std::string& s: vec)

lambda捕获

1
2
3
4
5
6
7
8
9
10
11
12
13
 // 获取对应消息类型的内容
std::string GetRichTextMessageXxxContent(const std::shared_ptr<model::Message>& message,
const std::map<model::MessageId, std::map<model::UserId, std::string>>& related_user_names,
const model::UserId& login_userid,
bool for_message_index) {
// ...
// 解析RichText内容
return DecodeRichTextMessage(message, [=](uint32_t item_type, const std::string& data) {
std::string output_text;
// ...
return output_text;
});
}

上述代码用于解析获取文本消息内容,涉及到富文本消息的解析和一些逻辑的计算,高频调用,他在解析RichText内容的callback中直接简单粗暴的按值捕获了所有变量,将所有变量都拷贝了一份,这里造成不必要的性能损耗,尤其上面那个std::map。这里可以改成按引用来捕获,规避不必要的拷贝。

lambda函数在捕获时会将被捕获对象拷贝,如果捕获的对象很多或者很占内存,将会影响整体的性能,可以根据需求使用引用捕获或者按需捕获:

1
2
3
auto func = &a{};

auto func = a = std::move(a){}; (限C++14以后)

隐式类型转换

1
2
3
4
std::map<int, std::string> myMap = {{1, "One"}, {2, "Two"}, {3, "Three"}};
for (const std::pair<int, std::string>& pair : myMap) {
//...
}

这里在遍历关联容器时,看着是const引用的,心想着不会发生拷贝,但是因为类型错了还是会发生拷贝,std::map 中的键值对是以 std::pair<const Key, T> 的形式存储的,其中key是常量。因此,在每次迭代时,会将当前键值对拷贝到临时变量中。在处理大型容器或频繁遍历时,这种拷贝操作可能会产生一些性能开销,所以在遍历时推荐使用const auto&,也可以使用结构化绑定:for(const auto& [key, value]: map){} (限C++17后)

函数返回值优化

RVO是Return Value Optimization的缩写,即返回值优化,NRVO就是具名的返回值优化,为RVO的一个变种,此特性从C++11开始支持。为了更清晰的了解编译器的行为,这里实现了构造/析构及拷贝构造、赋值操作函数,如下:

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
class Widget {
public:
Widget() {
std::cout << "Widget: Constructor" << std::endl;
}
Widget(const Widget& other) {
name = other.name;
std::cout << "Widget: Copy construct" << std::endl;
}
Widget& operator=(const Widget& other) {
std::cout << "Widget: Assignment construct" << std::endl;
name = other.name;
return *this;
}
~Widget() {
std::cout << "Widget: Destructor" << std::endl;
}
public:
std::string name;
};

Widget GetMyWidget(int v) {
Widget w;
if (v % 2 == 0) {
w.name = 1;
return w;
} else {
return w;
}
}
int main(){
const Widget& w = GetMyWidget(2); // (1)
Widget w = GetMyWidget(2); // (2)
GetMyWidget(2); // (3)
return 0;
}

运行上面代码,跑出的结果:

1
2
3
4
5
6
7
8
9
未优化:(msvc 2022, C++14)
Widget: Constructor
Widget: Copy construct
Widget: Destructor
Widget: Destructor

优化后:
Widget: Constructor
Widget: Destructor

针对上面(1)(2)(3)的调用,我之前也是有点迷惑,以为要减少拷贝必须得用常引用来接,但是发现编译器进行返回值优化后(1)(2)(3)运行结果都是一样的,也就是日常开发中,针对函数中返回的临时对象,可以用对象的常引用或者新的一个对象来接,最后的影响其实可以忽略不计的。不过个人还是倾向于对象的常引用来接,一是出于没有优化时(编译器不支持或者不满足RVO条件)可以减少一次拷贝,二是如果返回的是对象的引用时可以避免拷贝。但是也要注意不要返回临时对象的引用。

1
2
3
4
5
6
7
// pb协议接口实现
inline const ::PB::XXXConfig& XXConfigRsp::config() const {
//...
}
void XXSettingView::SetSettingInfo(const PB::XXConfigRsp& rsp){
const auto config = rsp.config(); // 内部返回的是对象的引用,这里没有引用来接导致不必要的拷贝
}

当遇到上面这种返回对象的引用时,外部最好也是用对象的引用来接,减少不必要的拷贝。

此外,如果Widget的拷贝赋值操作比较耗时,通常在使用函数返回这个类的一个对象时也是会有一定的讲究的。

1
2
3
4
// style 1
Widget func(Args param);
// style 2
bool func(Widget* ptr, Args param);

上面的两种方式都能达到同样的目的,但直观上的使用体验的差别也是非常明显的:

style 1只需要一行代码,而style 2需要两行代码,可能大多数人直接无脑style 1

1
2
3
4
5
// style 1
Widget obj = func(params);
// style 2
Widget obj;
func(&obj, params);

但是,能达到同样的目的,消耗的成本却未必是一样的,这取决于多个因素,比如编译器支持的特性、C++语言标准的规范强制性等等。

看起来style 2虽然需要写两行代码,但函数内部的成本却是确定的,只会取决于你当前的编译器,外部即使采用不同的编译器进行函数调用,也并不会有多余的时间开销和稳定性问题。使用style 1时,较复杂的函数实现可能并不会如你期望的使用RVO优化,如果编译器进行RVO优化,使用style 1无疑是比较好的选择。利用好编译器RVO特性,也是能为程序带来一定的性能提升。

函数传参使用对象的引用

effective C++中也提到了:以pass-by-reference-to-const替换pass-by-value 指在函数参数传递时,将原本使用”pass-by-value”(按值传递)的方式改为使用
“pass-by-reference-to-const”(按常量引用传递)的方式。

在 “pass-by-value” 中,函数参数会创建一个副本,而在 “pass-by-reference-to-const” 中,函数参数会成为原始对象的一个引用,且为了避免修改原始对象,使用了常量引用。

通过使用 “pass-by-reference-to-const”,可以避免在函数调用时进行对象的拷贝操作,从而提高程序的性能和效率;还可以避免对象被切割问题:当一个派生类对象以传值的方式传入一个函数,但是该函数的形参是基类,则只会调用基类的构造函数构造基类部分,派生类的新特性将会被切割。此外,使用常量引用还可以确保函数内部不会意外地修改原始对象的值。

std::shared_ptr线程安全

shared_ptr相信大家都很熟悉,但是一提到是否线程安全,可能很多人心里就没底了,借助本节,对shared_ptr线程安全方面的问题进行分析和解释。shared_ptr的线程安全问题主要有两种:

  1. 引用计数的加减操作是否线程安全;
  2. shared_ptr修改指向时是否线程安全。

引用计数

shared_ptr中有两个指针,一个指向所管理数据的地址,另一个指向执行控制块的地址。

执行控制块包括对关联资源的引用计数以及弱引用计数等。在前面我们提到shared_ptr支持跨线程操作,引用计数变量是存储在堆上的,那么在多线程的情况下,指向同一数据的多个shared_ptr在进行计数的++或–时是否线程安全呢?

引用计数在STL中的定义如下:

1
2
_Atomic_word _M_use_count;   // #shared
_Atomic_word _M_weak_count; // #weak + (#shared != 0)

当对shared_ptr进行拷贝时,引入计数增加,实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
template <>
inline bool _Sp_counted_base<_S_atomic>::_M_add_ref_lock_nothrow() noexcept {
// Perform lock-free add-if-not-zero operation.
_Atomic_word __count = _M_get_use_count();
do {
if (__count == 0) return false;
// Replace the current counter value with the old value + 1, as
// long as it's not changed meanwhile.
} while (!__atomic_compare_exchange_n(&_M_use_count, &__count, __count + 1, true, __ATOMIC_ACQ_REL,
__ATOMIC_RELAXED));
return true;
}

template <>
inline void _Sp_counted_base<_S_single>::_M_add_ref_copy() {
++_M_use_count;
}

对引用计数的增加主要有以下2种方法:_M_add_ref_copy函数,对_M_use_count + 1,是原子操作。_M_add_ref_lock函数,是调用__atomic_compare_exchange_n``实现的``,主要逻辑仍然是_M_use_count + 1,而该函数是线程安全的,和_M_add_ref_copy的区别是对不同_Lock_policy有不同的实现,包含直接加、原子操作加、加锁。

因此我们可以得出结论:在多线程环境下,管理同一个数据的shared_ptr在进行计数的增加或减少的时候是线程安全的,这是一波原子操作。

修改指向

修改指向分为操作同一个shared_ptr对象和操作不同的shared_ptr对象两种。

多线程代码操作的是同一个shared_ptr的对象

比如std::thread的回调函数,是一个lambda表达式,其中引用捕获了一个shared_ptr对象

1
2
shared_ptr<A> sp1 = make_shared<A>();
std::thread td([&sp1] () {....});

又或者通过回调函数的参数传入的shared_ptr对象,参数类型是指针或引用:

指针类型:void fn(shared_ptr<A>* sp) { ... }std::thread td(fn, &sp1);
引用类型:void fn(shared_ptr<A>& sp) { ... }std::thread td(fn, std::ref(sp1));

当你在多线程回调中修改shared_ptr指向的时候,这时候确实不是线程安全的。

1
2
3
4
5
6
7
void fn(shared_ptr<A>& sp) {
if (..) {
sp = other_sp;
} else if (...) {
sp = other_sp2;
}
}

shared_ptr内数据指针要修改指向,sp原先指向的引用计数的值要减去1,other_sp指向的引用计数值要加1。然而这几步操作加起来并不是一个原子操作,如果多个线程都在修改sp的指向的时候,那么有可能会出问题。比如在导致计数在操作-1的时候,其内部的指向已经被其他线程修改过了,引用计数的异常会导致某个管理的对象被提前析构,后续在使用到该数据的时候触发coredump。当然如果你没有修改指向的时候,是没有问题的。也就是:

  • 同一个shared_ptr对象被多个线程同时读是安全的
  • 同一个shared_ptr对象被多个线程同时读写是不安全的

多线程代码操作的不是同一个shared_ptr的对象。

这里指的是管理的数据是同一份,而shared_ptr不是同一个对象,比如多线程回调的lambda是按值捕获的对象。

1
std::thread td([sp1] () {....});

或者参数传递的shared_ptr是值传递,而非引用:

1
2
3
4
void fn(shared_ptr<A> sp) {
...
}
std::thread td(fn, sp1);

这时候每个线程内看到的sp,他们所管理的是同一份数据,用的是同一个引用计数。但是各自是不同的对象,当发生多线程中修改sp指向的操作的时候,是不会出现非预期的异常行为的。也就是说,如下操作是安全的:

1
2
3
4
5
6
7
void fn(shared_ptr<A> sp) {
if (..) {
sp = other_sp;
} else if (...) {
sp = other_sp2;
}
}

尽管前面我们提到了如果是按值捕获(或传参)的shared_ptr对象,那么该对象是线程安全的,然而话虽如此,但却可能让人误入歧途。因为我们使用shared_ptr更多的是操作其中的数据,对齐管理的数据进行读写,尽管在按值捕获的时候shared_ptr是线程安全的,我们不需要对此施加额外的同步操作(比如加解锁),但是这并不意味着shared_ptr所管理的对象是线程安全的!请注意这是两回事。

最后再来看下std官方手册是怎么讲的:

All member functions (including copy constructor and copy assignment) can be called by multiple threads on different instances of shared_ptr without additional synchronization even if these instances are copies and share ownership of the same object. If multiple threads of execution access the same instance of shared_ptr without synchronization and any of those accesses uses a non-const member function of shared_ptr then a data race will occur; the shared_ptr overloads of atomic functions can be used to prevent the data race.

这段话的意思是,shared_ptr 的所有成员函数(包括复制构造函数和复制赋值运算符)都可以由多个线程在不同的 shared_ptr 实例上调用,即使这些实例是副本并且共享同一个对象的所有权。如果多个执行线程在没有同步的情况下访问同一个 shared_ptr 实例,并且这些访问中的任何一个使用了 shared_ptr 的非 const 成员函数,则会发生数据竞争;可以使用shared_ptr的原子函数重载来防止数据竞争。

我们可以得到下面的结论:

  1. 多线程环境中,对于持有相同裸指针的std::shared_ptr实例,所有成员函数的调用都是线程安全的。
    a. 当然,对于不同的裸指针的 std::shared_ptr 实例,更是线程安全的
    b. 这里的 “成员函数” 指的是 std::shared_ptr 的成员函数,比如 get ()reset ()operrator->()
  2. 多线程环境中,对于同一个std::shared_ptr实例,只有访问const的成员函数,才是线程安全的,对于非const成员函数,是非线程安全的,需要加锁访问。

首先来看一下 std::shared_ptr 的所有成员函数,只有前3个是 non-const 的,剩余的全是 const 的:

成员函数 是否const
operator= non-const
reset non-const
swap non-const
get const
operator、operator-> const
operator const
use_count const
operator bool const
unique const

讲了这么多,来个栗子实践下:

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
 ant::Promise<JsAPIResultCode, CefRefPtr<CefDictionaryValue>>
XXXHandler::OnOpenSelectContactH5(const JsAPIContext& context, std::shared_ptr<RequestType> arguments) {
ant::Promise<JsAPIResultCode, CefRefPtr<CefDictionaryValue>> promise;
base::GetUIThread()->PostTask(weak_lambda(this, [this, promise, context, arguments]() {

auto b_executed_flag = std::make_shared<std::atomic_bool>(false);
auto ext_param = xx::OpenWebViewWindow::OpenURLExtParam();
// ...
// SelectCorpGroupContact jsapi的回调
ext_param.select_group_contact_callback = [promise, b_executed_flag](
JsAPIResultCode resCode, CefRefPtr<CefDictionaryValue> res) mutable {
*b_executed_flag = true;
base::GetUIThread()->PostTask([promise, resCode, res]() {
promise.resolve(resCode, res);
});
};
// 窗口关闭回调
ext_param.dismiss_callback = [promise, b_executed_flag]() {
if (*b_executed_flag) {
return;
}
promise.resolve(JSAPI_RESULT_CANCEL, CefDictionaryValue::Create());
};
// ...
xx::OpenWebViewWindow::OpenURL(nullptr, url, false, ext_param);
}));
return promise;
}

该段代码场景是一个Jsapi接口,在接口中打开另一个webview的选人窗口,选人窗口操作后或者关闭时都需要回调下,将结果返回jsapi。选人完毕确认后会回调select_group_contact_callback,同时关闭webview窗口还会回调dismiss_callback,这俩回调里面都会回包,这里还涉及多线程调用。这俩回调只能调用一个,为了能简单达到这种效果,作者用std::shared_ptrstd::atomic_bool b_executed_flag来处理多线程同步,如果一个回调已执行就标记下,shared_ptr本身对引用计数的操作是线程安全的,通过原子变量std::atomic_bool来保证其管理的对象的线程安全。

std::map

1
2
3
4
5
6
7
8
9
10
11
12
13
// 定义数据缓存类
class DataCache {
private:
std::map<std::string, std::string> cache;
public:
void addData(const std::string& key, const std::string& value) {
cache[key] = value;
}
std::string getData(const std::string& key) {
return cache[key];
}
};

在上述示例中,简单定义了个数据缓存类,使用 std::map作为数据缓存,然后提供addData添加数据到缓存,getData从map缓存中获取数据。一切看起来毫无违和感,代码跑起来也没什么问题,但是如果使用没有缓存的key去getData, 发现会往缓存里面新插入一条value为默认值的记录。

需要注意的是,如果我们使用 [] 运算符访问一个不存在的键,并且在插入新键值对时没有指定默认值,那么新键值对的值将是未定义的。因此,在使用 [] 运算符访问 std::map 中的元素时,应该始终确保该键已经存在或者在插入新键值对时指定了默认值。

1
2
3
4
5
6
7
8
void addData(const std::string& key, const std::string& value) {
if(key.empty()) return;
cache[key] = value;
}
std::string getData(const std::string& key) {
const auto iter = cache.find(key);
return iter != cache.end() ? iter->second : "";
}

sizeof & strlen

相信大家都有过这样的经历,在项目中使用系统API或者与某些公共库编写逻辑时,需要C++与C 字符串混写甚至转换,在处理字符串结构体的时候就免不了使用sizeofstrlen,这俩看着都有计算size的能力,有时候很容易搞混淆或者出错。

sizeof 是个操作符,可用于任何类型或变量,包括数组、结构体、指针等,
返回的是一个类型或变量所占用的字节数; 在编译时求值,不会对表达式进行求值。

strlen 是个函数,只能用于以 null 字符结尾的字符串,返回的是一个以
null 字符(\0)结尾的字符串的长度(不包括 null 字符本身),且在运行时才会计算字符串的长度。

需要注意的是,使用 sizeof 操作符计算数组长度时需要注意数组元素类型的大小。例如,对于一个 int 类型的数组,使用 sizeof 操作符计算其长度应该为 sizeof(array) / sizeof(int)。而对于一个字符数组,使用strlen函数计算其长度应该为 strlen(array)。

1
2
char str[] = "hello";
char *p = str;
  • 此时,用sizeof(str)得到的是6,因为hello是5个字符,系统储存的时候会在hello的末尾加上结束标识\0,一共为6个字符;
  • sizeof(p)得到的却是4,它求得的是指针变量p的长度,在32位机器上,一个地址都是32位,即4个字节。
  • sizeof(*p)得到的是1,因为*p定义为char,相当于一个字符,所以只占一个字节。
  • strlen(str),得到的会是5,因为strlen求得的长度不包括最后的\0
  • strlen(p),得到的是5,与strlen(str)等价。

上面的是sizeof和strlen的区别,也是指针字符串和数组字符串的区别。

1
2
3
4
5
6
7
8
9
10
11
12
const char* src = "hello world";
char* dest = NULL;
int len = strlen(src); // 这里很容易出错,写成sizeof(src)就是求指针的长度,即4
dest = (char*)malloc(len + 1); // 这里很容易出错,写成len
char* d = dest;
const char* s = &src[len - 1]; // 这里很容易出错,写成len
while (len-- != 0) {
*d++ = *s--;
}
*d = '\0'; // 这句很容易漏写
printf("%sIn", dest);
free(dest);

std::async真的异步吗?

std::async是C++11开始支持多线程时加入的同步多线程构造函数,其弥补了std::thread没有返回值的问题,并加入了更多的特性,使得多线程更加灵活。

顾名思义,std::async是一个函数模板,它将函数或函数对象作为参数(称为回调)并异步运行它们,最终返回一个std::future,它存储std::async()执行的函数对象返回的值,为了从中获取值,程序员需要调用其成员 future::get.

std::async一定是异步执行吗?先来看段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
int calculate_sum(const std::vector<int>& numbers) {
std::cout << "Start Calculate..." << std::endl; // (4)
int sum = 0;
for (int num : numbers) {
sum += num;
}
return sum;
}
int main() {
std::vector<int> numbers = { 88, 101, 56, 203, 72, 135 };
std::future<int> future_sum = std::async(calculate_sum, numbers);
std::cout << "Other operations are in progress..." << std::endl; // (1)
int counter = 1;
while (counter <= 1000000000) {
counter++;
}
std::cout << "Other operations are completed." << std::endl; // (2)
// 等待异步任务完成并获取结果
int sum = future_sum.get();
std::cout << "The calculation result is:" << sum << std::endl; // (3)
return 0;
}

直接运行上面的代码,输出结果如下:

1
2
3
4
Other operations are in progress...
Start Calculate...
Other operations are completed.
The calculation result is:655

执行完(1) 就去执行(4), 然后再(2)(3),说明这里是异步执行的。那可以认为async一定是异步的吗?

如果改成std::async(std::launch::deferred, calculate_sum, numbers); 运行结果如下:

1
2
3
4
5
6
7
Other operations are **in** progress...

Other operations are completed.

Start Calculate...

The calculation result is:655

执行完(1) (2), 然后再(4)(3), 说明是真正调用std::future<>::get()才去执行的,如果没有调用get,那么就一直不会执行。

std::async是否异步受参数控制的,其第一个参数是启动策略,它控制 std::async 的异步行为。可以使用 3 种不同的启动策略创建 std::async ,即:

  • std::launch::async 它保证异步行为,即传递的函数将在单独的线程中执行
  • std::launch::deferred 非异步行为,即当其他线程将来调用get()来访问共享状态时,将调用函数
  • std::launch::async | std::launch::deferred 它是默认行为。使用此启动策略,它可以异步运行或不异步运行,具体取决于系统上的负载,但我们无法控制它

如果我们不指定启动策略,其行为类似于std::launch::async |
std::launch::deferred.
也就是不一定是异步的。

Effective Modern C++ 里面也提到了,如果异步执行是必须的,则指定std::launch::async策略。

内存泄漏?

对于这样的一个函数:

1
void processwidget(std::shared_ptrpw, int);

如果使用以下方式调用,会有什么问题吗?

1
processwidget(std::shared_ptr(new Widget), priority());

一眼看上去觉得没啥问题,甚至可能新手C++开发者也会这么写,其实上面调用可能会存在内存泄漏。

编译器在生成对processWidget函数的调用之前,必须先解析其中的参数。processWidget函数接收两个参数,分别是智能指针的构造函数和整型的函数priority()。在调用智能指针构造函数之前,编译器必须先解析其中的new Widget语句。因此,解析该函数的参数分为三步:

  1. 调用priority();
  2. 执行new Widget.
  3. 调用std:shared_ptr构造函数

C++编译器以什么样的固定顺序去完成上面的这些事情是未知的,不同的编译器是有差异的。在C++中可以确定(2)一定先于(3)执行,因为new Widoet还要被传递作为std::shared_ptr构造函数的一个实参。然而,对于priority()的调用可以在第(1)、(2)、(3)步执行,假设编译器选择以(2)执行它,最终的操作次序如下:
(1) 执行new Widget; (2) 调用priority(): (3)调用std::shared_ptr构造函数。但是,如果priority()函数抛出了异常,经由new Widget返回的指针尚未被智能指针管理,将会遗失导致内存泄漏。

解决方法: 使用一个单独的语句来创建智能指针对象。

1
2
3
4
std::shared ptr<Widget> pw(new widget); // 放在单独的语句中
processwidget(pw, priority()):
// or
processwidget(std::make_shared<Widget>(), priority());

编译器是逐语句编译的,通过使用一个单独的语句来构造智能指针对象,编译器就不会随意改动解析顺序,保证了生成的机器代码顺序是异常安全的。

总结:尤其是在跨平台开发的时候更加要注意这类隐晦的异常问题,Effective
C++中也提到了,要以独立语句将new对象存储于智能指针内。如果不这样做,一旦异常被抛出,有可能导致难以察觉的内存泄漏。

const/constexpr

如果C++11中引入的新词要评一个”最令人困惑”奖,那么constexprhen很有可能获此殊荣。当它应用于对象时,其实就是一个加强版的const,但应用于函数时,却有着相当不同的意义。在使用 C++ constconsterpx的时候,可能都会犯晕乎,那constexprconst都有什么区别,这节简单梳理下。

const

const一般的用法就是修饰变量、引用、指针,修饰之后它们就变成了常量,需要注意的是const并未区分出编译期常量和运行期常量,并且const只保证了运行时不直接被修改。

一般的情况,const 也就简单这么用一下,const 放在左边,表示常量:

1
2
3
4
5
const int x = 100; // 常量

const int& rx = x; // 常量引用

const int* px = &x; // 常量指针

给变量加上const之后就成了”常量”,只能读、不能修改,编译器会检查出所有对它的修改操作,发出警告,在编译阶段防止有意或者无意的修改。这样一来,const常量用起来就相对安全一点。在设计函数的时候,将参数用 const 修饰的话,可以保证效率和安全。

除此之外,const 还能声明在成员函数上,const 被放在了函数的后面,表示这个函数是一个”常量”,函数的执行过程是 const 的,不会修改成员变量。

此外,const还有下面这种与指针结合的比较绕的用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int a = 1;
const int b = 2;
const int* p = &a;
int const* p1 = &a;

// *p = 2; // error C3892: “p”: 不能给常量赋值
p = &b;
// *p1 = 3; // error C3892: “p1”: 不能给常量赋值
p1 = &b;

int* const p2 = &a;
//p2 = &b; // error C2440: “=”: 无法从“const int *”转换为“int *const ”
*p2 = 5;
const int* const p3 = &a;
  • const int 与 int const并无很大区别,都表示: 指向常量的指针,可以修改指针本身,但不能通过指针修改所指向的值。
  • 而对于int *const,则是表示:一个常量指针,可以修改所指向的值,但不能修改指针本身。
  • const int* const 表示一个不可修改的指针,既不能修改指针本身,也不能通过指针修改所指向的值。

总之,const默认与其左边结合,当左边没有任何东西则与右边结合。

constexpr

表面上看,constexpr不仅是const,而且在编译期间就已知,这种说法并不全面,当它应用在函数上时,就跟它名字有点不一样了。使用constexpr关键字可以将对象或函数定义为在编译期间可求值的常量,这样可以在编译期间进行计算,避免了运行时的开销。

constexpr对象

必须在编译时就能确定其值,并且通常用于基本数据类型。例如:

constexpr int MAX_SIZE = 100; // 定义一个编译时整型常量

constexpr double PI = 3.14159; // 定义一个编译时双精度浮点型常量

const和constexpr变量之间的主要区别在于变量的初始化,const可以推迟到运行时,constexpr变量必须在编译时初始化。const 并未区分出编译期常量和运行期常量,并且const只保证了运行时不直接被修改,而constexpr是限定在了编译期常量。简而言之,所有constexpr对象都是const对象,而并非所有的const对象都是constexpr对象。

  • 当变量具有字面型别(literal type)(这样的型别能够持有编译期可以决议的值)并已初始化时,可以使用constexpr来声明该变量。如果初始化由构造函数执行,则必须将构造函数声明为constexpr.
  • 当满足这两个条件时,可以声明引用constexpr:引用的对象由常量表达式初始化,并且在初始化期间调用的任何隐式转换也是常量表达式。
  • constexpr变量或函数的所有声明都必须具有constexpr说明符。
1
2
3
4
5
6
constexpr float x = 42.0;
constexpr float y{108};
constexpr float z = exp(5, 3);
constexpr int i; // Error! Not initialized
int j = 0;
constexpr int k = j + 1; //Error! j not a constant expression

constexpr函数

是指能够在编译期间计算结果的函数。它们的参数和返回值类型必须是字面值类型,并且函数体必须由单个返回语句组成。例如:

1
2
3
constexpr int square(int x) {
return x * x;
}

constexpr int result = square(5);// 在编译期间计算结果,result 的值为 25

使用 constexpr 可以提高程序的性能和效率,因为它允许在编译期间进行计算,避免了运行时的计算开销。同时,constexpr 还可以用于指定数组的大小、模板参数等场景,提供更灵活的编程方式。

对constexpr函数的理解:

  1. constexpr函数可以用在要求编译器常量的语境中。在这样的语境中,如果你传给constexpr函数的实参值是在编译期已知的,则结果也会在编译期间计算出来。如果任何一个实参值在编译期间未知,则代码将无法通过编译。
  2. 在调用constexpr函数时,若传入的值有一个或多个在编译期间未知,则它的运作方式和普通函数无异,也就是它也是在运行期执行结果的计算。也就是说,如果一个函数执行的是同样的操作,仅仅应用语境一个是要求编译期常量,一个是用于所有其他值的话,那就不必写两个函数。constexpr函数就可以同时满足需求。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    constexpr float exp(float x, int n) {
    return n == 0 ? 1 :
    n % 2 == 0 ? exp(x * x, n / 2) :
    exp(x * x, (n - 1) / 2) * x;
    }

    constexpr auto x = 5;
    constexpr auto n = 3;
    constexpr int result = exp(x, n); // ok, 前面加上constexpr,进行编译期间求值,单步调试根本进不去

    int xx = 4;
    int nn = 3;
    //constexpr int result2 = exp(xx, nn); // error C2131: 表达式的计算结果不是常数
    int result3 = exp(xx, nn); // ok, 这里作为普通函数来使用
    比起非constexpr对象或constexpr函数而言,constexpr对象或是constexpr函数可以用在一个作用域更广的语境中。只要有可能使用constexpr,就使用它吧。

最后

欢迎C++大佬们一起交流经验,站在巨人的肩膀上,写的有问题的地方欢迎拍砖补充。

  1. 万字避坑指南!C++的缺陷与思考(下) - 知乎
  2. 智能指针-使用、避坑和实现
  3. c++ - What’s the difference between constexpr and const? - Stack Overflow
  4. https://stackoverflow.com/questions/402283/stdwstring-vs-stdstring

[转载] try catch 失效排查 - 探索异常处理机制的迷雾

原文地址

C++ 异常处理机制 try catch 在快手 App 内突然失效,引发大量未捕获异常导致的崩溃。本文介绍稳定性团队排查此次问题的过程,问题的根本原因以及修复规避方案,最后梳理异常处理流程,知其然,知其所以然,方能在问题出现时冷静应对。

背景介绍

快手 App iOS 端在最近的一个版本上线前, 自动化测试流程上报了非常多 C++ 未捕获异常(invalid_argument 、out_of_range 等)导致的崩溃,崩溃堆栈在版本周期内并没有修改记录,并且在崩溃的代码路径上存在 try catch 语句,catch 语句声明的异常类型是 exception。

invalid_argument 和 out_of_range 都是 logic_error 的子类,logic_error 是 exception 的子类。

descript

根据 try catch 的工作原理,catch 语句声明 exception 可以捕获子类
out_of_range 和 invalid_argument。

catch <type> @ExcType
This clause means that the landingpad block should be entered if the exception being thrown is of type @ExcType or a subtype of @ExcType. For C++, @ExcType is a pointer to the std::type_info object (an RTTI
object) representing the C++ exception type.

以 mmkv 的崩溃堆栈为例,readString 方法抛出了 std::out_of_range,这个异常应该在 decodeOneMap 方法内被捕获,而不应该触发崩溃。

descript

mmkv::CodedInputData::readString 抛异常代码:

descript

mmkv::MiniPBCoder::decodeOneMap 中捕获异常代码:
descript

debug 线上的版本,124 行可以正常输出错误日志,在 mmkv 没有任何改动的情况下,自动化测试版本 catch 语句不能正常捕获异常了。

排查过程

本人王者荣耀钻五选手,对这个游戏有着相当深刻的理解,接下来的排查过程就以一局游戏为例吧。(真实原因是起一个段落标题和函数命名一样困难!)

全军出击

同样深入理解游戏的人脑海中已经有了画面,游戏开局,小兵缓缓抵达了战场,小鲁班 A 了一下兵线,first blood 人没了……

mmkv 里面的崩溃堆栈简化后如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void throw_exception() {
throw std::out_of_range("out_of_range");
}

int catch_exception() {
auto block = []() {
throw_exception();
};

try {
block();
} catch (std::exception& e) {
std::cout << "Caught exception." << e.what() << std::endl;
}
return 0;
}

这段代码在 demo 工程里面运行,使用 exception 类型可以 catch 子类型 out_of_range。但是把相同的代码复制到快手 App,catch 语句不会执行。当时怀疑和快手 App 的编译选项改动有关,找到架构那边的同学,确认编译参数近期没有任何改动。

21 年年底处理过一次 try catch 失效导致的崩溃,这种超乎常理的问题总是令人印象深刻。上次的原因是 hook 系统方法 objc_msgSend 后没有添加 CFI 指令,导致 unwind 回溯到 objc_msgSend 后中断,无法继续向上查找调用栈中的 catch 语句。所以在排查这个问题时首先想到的是判断 unwind 流程是否正常。这个判断用代码比较容易实现,测试用例里面,新增一个 catch 语句,捕获具体的子类,然后在快手 App 运行。

1
2
3
4
5
6
7
try {
block();
} catch (std::exception& e) {
std::cout << "Caught exception" << e.what() << std::endl;
} catch (std::out_of_range& e) {
std::cout << "Caught out_of_range" << e.what() << std::endl; << ---- 会执行
}

运行上述代码后,执行了第二个 catch 块,out_of_range 捕获了 out_of_range,说明 unwind 流程是正常的,可以回溯到 try catch 语句。

测试用例中运行结果同时表示:

  1. out_of_range 实例 is type of  out_of_range 成立。
  2. out_of_range 实例 a subtype of exception 不成立。

第二条显然不符合预期,所以在快手 App 内异常没有被捕获的原因是判断 exceptionout_of_range 的继承关系时存在错误。尝试使用 is_base_of 在快手 App 内判断 out_of_range 是否是 exception 的子类,返回的结果 rv 是 true

1
2
3
4
5
6
bool rv = std::is_base_of<std::logic_error, std::out_of_range>::value
&& std::is_base_of<std::exception, std::logic_error>::value
&& std::is_base_of<std::exception, std::out_of_range>::value;
if (rv) {
abort(); << ---- 会执行
}

然而,在异常处理流程中,判断 catchexception 类型是否能匹配抛出子类型 out_of_range 异常时是通过 is_base_of 方法吗?这个问题现在来看比较低级,但是在当时缺少对整个异常处理流程的认知,不知从何处开始调试,只能暂时放下这个问题,开始其它方向的排查。

请求打野支援

一顿操作猛如虎,一看战绩 0-5。打野,速速来 gank!

这是一个新增并且可以稳定复现的崩溃,因此一定能够查找到是哪个 MR 引入的。稳定性组的两个同事,从出现崩溃的 commit 开始二分查找之前一天的
MR,最终锁定了动态库改静态库这个提交 ,这个提交 merge 之后构造的测试用例可以复现崩溃。之后根据自动化流程上报的堆栈,修改 mmkv readString 方法,调用即抛出 out_of_range 异常,在 decodeOneMap 方法内异常没有被 catch,实锤了是这个 MR 引入的问题。

这个 MR 并不复杂,修改点不多,将部分动态库改为静态库集成到快手 App。里面删除了一些动态库的编译选项,和 C++ 相关的只有一个 CLANG_CXX_LANGUAGE_STANDARD,用于指定 Clang 编译器在编译 C++ 代码时所使用的语言标准。

descript
descript

虽然定位了问题引入的 MR,但是此时根据代码 diff 还是看不出具体的原因。

集合准备团战

不是一个人的王者,而是团队的荣耀!

动态库改静态库是最近一次活动必须要上的需求,否则会存在 ANR 影响活动效果,所以定位到的 MR 不能被直接回滚。周四就要提审,这个问题不被解决一定会阻塞提审流程,影响到活动版本的覆盖率。

周三晚上,稳定性组的负责人开始组织整个组同学参与进来一起讨论解决方案。在这次讨论中,首先排除了一个方向: 代码 diff 中删除动态库的 C++ 版本对快手 App编译环境无影响。之后初步梳理了 C++ exception handling 的逻辑,明确崩溃场景下使用 __gxx_personality_v0 routine 方法来判断栈帧是否能 catch 异常。之后 debug __gxx_personality_v0 得出了一个非常关键的信息,导致 try catch 失效的直接原因是快手 App 内多了一份 exception  的 type_info:

std::exception 的 type info 对不上,name 都是一样的,但是一个在 libc++abi.dylib, 一个在快手 App内,正常应该都会在 libc++abi.dylib,也就是说快手 App多了一份 std::exception 信息。

在定位到直接原因后,接下来开始查找  std::exception 的 type_info
是被哪个编译 target 引入快手 App的。image lookup 可以查看 type_info
指针地址详细的信息:

1
2
3
(lldb) image lookup -a 0x000000010396c970
Address: Example[0x0000000101498970] (Example.__DATA.__const + 39952)
Summary: Example`typeinfo for std::exception

0x000000010396c970 存储在 __DATA.__const 段, __DATA.__const 是一个特殊的 section,用于存储只读的常量数据,在一般情况下,__const section 中存储常量的顺序是按照它们在源代码中出现的顺序来排列的。尝试查看 0x000000010396c970 这个地址附近存储的信息。

1
2
3
4
5
6
7
8
9
10
11
12
(lldb) image lookup -a 0x000000010396c978
Address: Example[0x0000000101498978] (Example.__DATA.__const + 39960)
Summary: Example`typeinfo for std::exception + 8
(lldb) image lookup -a 0x000000010396c980
Address: Example[0x0000000101498980] (Example.__DATA.__const + 39968)
Summary: Example`typeinfo for std::bad_alloc
(lldb) image lookup -a 0x000000010396c960
Address: Example[0x0000000101498960] (Example.__DATA.__const + 39936)
Summary: Example`vtable for std::__1::__function::__base<bool (Runtime::JSState&)> + 56
(lldb) image lookup -a 0x000000010396c950
Address: Example[0x0000000101498950] (Example.__DATA.__const + 39920)
Summary: Example`vtable for std::__1::__function::__base<bool (Runtime::JSState&)> + 40

0x000000010396c970 - 0x10 的位置找到了 Runtime::JSState。Runtime 这个符号在组件 dummy 内定义。取 dummy 的编译产物 libdummy.a,发现 .a 里面 const 段,存在 exception 的 type info。

1
2
3
00000000000070b0 (__DATA,__const) weak private external __ZTISt9exception
00000000000070c0 (__DATA,__const) weak private external __ZTISt9bad_alloc
00000000000070d8 (__DATA,__const) weak private external __ZTISt20bad_array_new_length
1
2
➜  Exception git:(main) ✗ c++filt __ZTISt9exception
typeinfo for std::exception

测试 demo 工程依赖组件 dummy 后编译,使用 exception 无法 catch out_of_range ,实锤了是这个组件引入的问题。在 podspec 里面查看 dummy
的编译选项,发现禁用了 RTTI,在 Xcode 里面将这个选项修改为 YES 之后,try
catch 失效导致的未捕获异常崩溃不再复现。

descript

dummy 是这次动态库改静态库的需求中,改动的动态库间接依赖的静态库,主可执行文件之前不会直接依赖 dummy。宿主动态库以静态库方式集成到快手 App后,dummy
同样以静态库的方式集成到快手 App,导致 std::exceptiontype_info 被引入主可执行文件。定位到了引入的子库 dummy 和 try catch 失效的原因后,接下来就是查找对应的解决方案。

VICTORY

敌方水晶已被击破!

方案 1

最快速的修改是将 dummy 编译选项 GCC_ENABLE_CPP_RTTI 修改为 YES,但是因为其特殊的业务场景不允许被修改。

方案 2

dummy 删除 std::exception 的依赖。最终以失败告终,libdummy.a 仍然存在 exception type_info,当时应该是没有删除干净,仍然存在 exception 的子类。

方案 3

这个方案和方案 2 在同步进行。崩溃的直接原因是动改静之后,将 dummy 集成到了快手 App,导致主可执行文件多出了一份 std::exception。虽然不能将全量的动态库回滚,单独将 dummy 回滚为动态库也能解决问题。

方案 4

反向修改。在查看 libdummy.a 符号时,发现这个库同时存在 exception 的子类 std::bad_alloctype_info,在快手 App内使用 exception 可以 catch bad_alloc,说明父类和子类都在主可执行文件时,try catch exception 也可以捕获子类。如果 dummy 包含了所有的子类,try catch 失效的问题也能解决。这个方案虽然能修复我们遇到的问题,但是我们无法评估这样的修改是否会产生额外的影响。

方案 5

事后我手同事又提供一个解决方案,添加如下cflags:set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -D_LIBCPP_TYPEINFO_COMPARISON_IMPLEMENTATION=2"),添加之后,即使存在两份 type_info,type_info 的判等可以走到 strcmp 的逻辑里面。

最终选择了方案 3,风险最低,不会影响业务逻辑,并且修改的时间成本较低。方案 3 并不是把 try catch 失效的影响范围缩小到了dummy 里面,根据方案 4 的原理,方案 3 并不会导致 dummy 内的 try catch 失效。最终由架构组同学负责修改,修改后验证可行。

复盘

段位在钻五之上的人一定是懂复盘的,要从之前胜利的对局中吸取经验,这样在以后的对局中才能一直赢,一直赢,守住钻五的牌面。

try catch 失效的问题虽然被解决了,但是排查过程中遇到的一些问题还没有找到答案。从稳定性的角度出发,首要问题是理清 C++ 异常处理机制,exception handing 如何查找 catch 块以及判断 catch 块是否匹配正在抛出的异常?在问题解决后,查阅了一些资料,对 C++ 异常处理机制有了一些基本的了解。

在函数调用栈帧中每个函数都对应 1 个或者 0 个 exception table,里面包含了这个函数内不同的 PC 区间可以 catch 的异常类型,以及 catch 后的跳转地址,在异常抛出时,查找调用栈中可以捕获抛出异常的栈帧时,会依次使用调用栈中不同的栈帧对应的 exception table,根据栈帧跳转时的 PC,匹配栈帧 exception table 内记录的地址区间,找到这个区间可以 catch 的异常类型,使用这个类型来判断是否能 catch 抛出的异常,如果可以 catch 则跳转到区间对应的跳转地址,继续执行 catch 块的代码,否则继续查找上一个栈帧。 

接下来将结合具体的 demo 用例、编译产物、源码和大家一起分享下异常处理流程。

throw

throw 说起,编译时 throw 会被替换为 __cxa_throw, __cxa_throw
会调用 _Unwind_RaiseException, 如果_Unwind_RaiseException 未找到捕获当前 exceptionlanding pad 或者在查找过程中出现错误,会 returnreturn__cxa_throw 方法继续执行 failed_throwfailed_throw 内执行__terminate。查找到可以捕获异常的 landing pad 之后会跳转到对应的地址,不会 return 也就不会触发崩溃。

descript

这里的 landing pad 可以理解为当异常捕获时继续执行的函数调用入口,这个函数接收两个参数 exception structureexception type_infotypetable 中的索引。在 landing pad 函数内会根据 type_info 的索引值来决定具体执行的 catch 块。landing pad 的另一个语义是 cleanup 调用入口。

_Unwind_RaiseException

_Unwind_RaiseException 包含了异常处理的两个核心流程 phase1
phase2,对应 searchcleanup

在异常抛出时,需要遍历栈帧,查找可以捕获异常的 catch 语句。search 阶段使用 libunwind 依次回退栈帧,恢复寄存器信息,并根据 PC 二分查找 __unwind_info,获取栈帧对应的  personality 函数,以及执行 personality 函数依赖的 exception table -- LSDA(Language Specific Data Area)。之后调用 personality 解析 LSDA 来判断当前栈帧是否能 catch 异常,如果可以会记录栈帧相应的信息。

search 阶段如果没有查找到可以处理异常的栈帧,会返回到 __cxa_throw
方法内,执行 terminate,查找成功会继续执行 cleanup 阶段。当异常发生时,从 throwcatch 之间的函数执行中断,cleanup 等价于在函数退出时执行的清理操作。以下面的代码为例,A 调用 B,B 调用 C,A catch C 抛出的异常,在 B 调用 C 之前定义了 m_cls。未发生异常时 m_cls 在函数末尾触发析构方法,异常发生时 B 函数执行中断,m_clscleanup 阶段执行析构方法。

1
2
3
4
5
6
7
8
9
10
11
12
void funcC() {
// 抛出异常
}

void funcB() {
MyClass m_cls;
funcC();
}

void funcA() {
// catch 异常
}

cleanup 会再次回退栈帧,并执行局部变量的清理,保证资源可以正常释放,当回退到 search 阶段记录的栈帧,会使用 search 缓存的跳转地址,执行 resume,实现 throwcatch 块的跳转。

异常处理流程为什么会拆分为 search 和 cleanup 呢?官方给出的解释如下:

A two-phase exception-handling model is not strictly necessary to implement C++ language semantics, but it does provide some benefits. Forexample, the first phase allows an exception-handling mechanism to dismiss an exception before stack unwinding begins, which allows resumptive exception handling (correcting the exceptional condition and resuming execution at the point where it was raised). While C++ does not support resumptive exception handling, other languages do, and the two-phase model allows C++ to coexist with those languages on the stack.

两个阶段的异常处理模型对于 C++ 并不是严格必需的,但是它可以带来一些好处。比如,第一阶段允许异常处理机制在栈帧展开之前消除异常,这样可以进行恢复式异常处理(对异常情况进行修复,然后在抛出异常的地方继续执行),虽然 C++ 不支持恢复式的异常处理,但其它语言支持,两阶段模型允许 C++
与那些语言在堆栈上共存。

searchcleanup 阶段,都需要依赖 LSDA 判断当前栈帧是否能 catch 异常,了解 LSDA 的数据结构对于理解异常处理流程至关重要。

LSDA

LSDA 包含 header, call site table, action table 和一个可选的 type table。判断当前栈帧是否能 catch 异常时,涉及到三次查表的过程,

  1. 根据当前栈帧的 PC 查找 call site table,获取地址区间匹配的的 call site。
  2. 根据 call site 记录的 action 索引值在 action table 取 action。
  3. 根据 action 中 type_info 的索引值在 type table 中取 type_info。

之后根据 type_info 判断是否能 catch 异常,是则记录(phase1)或者跳转(phase2)到 call site 中 lpad 字段记录的的 landing pad address。否则继续向上回溯栈帧,并重复上述过程。

LSDA 的数据结构如下图所示:

descript

header

LPStart: 默认是函数的起始位置。
TTBase: 记录 type table 的相对位置。

call site record

start & len: 记录了可能会抛出异常的 PC 区间,这个区间是相对于函数起始位置的偏移量。
lpad: 记录匹配之后跳转的”函数地址” landing pad address 的相对位置。
action_offset: 记录 action table 中的索引值,action 用于查找 call site 能够捕获的异常类型。

action record:

filter:记录了 catch 块中异常类型的 typeinfo 在 type table 中的索引。
next_ar:指向下一个 action 或者 end,如果 filter 类型不匹配,则继续查找 next_ar。遍历到 end 未找到匹配的 filter 表示当前 call site 所包含的 catch 块都不能处理 exception。

type table

编译器会将 catch 块异常类型的 typeinfo 信息存储在 type table 中。typeinfo是 C++标准库提供的类,它包含了与类型相关的运行时信息。

举个例子

以下面的代码为例,抛出异常并在当前函数内捕获异常:

descript

简化后判断是否能 catch 异常的过程:

catch_exception 将函数的地址区间拆分为不同的 call site,21 行 try 块所在的 call site ,记录了这个区间能 catch 的异常类型 out_of_rangeexception,以及 catch 后的跳转地址 22 行,在 22 行根据抛异常的类型判断具体执行的 catch 语句。try 块内抛出的异常类型 out_of_range 和 call site 记录的异常类型匹配,异常被捕获,根据匹配的类型继续执行第一个 catch 语句。如果不匹配,会继续在调用栈向上查找,如果上一个栈帧跳转到 catch_exception 的地址也在 try 块内,则继续使用对应的 call site 判断,如果仍然不能匹配或者跳转地址本身就不在 try 块内则继续查找上一个栈帧。

接下来根据 catch_exception 方法生成的实际数据推演运行时捕获 exception 的流程。

catch_exception 方法内 catch 两种类型的异常 out_of_range 和 exception, 对应的 type table 如下所示, 其中 0 表示会 catch 所有的异常:

Catch TypeInfos type_info
TypeInfo 3 0
TypeInfo 2 __ZTISt12out_of_range@GOT-Ltmp28
TypeInfo 1 __ZTISt9exception@GOT-Ltmp29

action table 如下所示,action table entry 中 filter 表示上述 type table 中的索引值,以 Action Record 4 为例表示使用 type table 中索引 1 对应的 std::exception 判断是否能 catch 异常,判断执行的逻辑是 std::exception 是否和抛出的异常类型是同一个类型或者有相同的基类。而 Action Record 5next_ar 指向 action 4,表示的是一个链表,会先使用 action 5 中的索引值 2 对应的 std::out_of_range 判断,如果不能 catch 异常,会继续执行 action 4 的判断逻辑,两者任意一个类型匹配抛出异常的类型都表示异常可以被捕获。

Action Record Filter(索引) Next Action
Action Record 1 0(Cleanup) No further actions
Action Record 2 1 Continue to action 1
Action Record 3 2 Continue to action 2
Action Record 4 1 No further actions
Action Record 5 2 Continue to action 4

catch_exception 方法在编译时生成的 call site table 如下,其中的 Lfunc_beginXLtmpX 表示汇编代码的标签,可以理解为代码段中地址的别名。

Call Site 3 为例,表示当 PC 处于 Ltmp3Ltmp4 地址区间内,会根据上述 action table 中的 action 5 判断是否能 catch 异常,是则会跳转到 call site 记录的 landing pad 地址 Ltmp5 处,执行 catch 语句处理异常。

Call Site 1 2 和 4 记录的 action 都是 0, 0 表示需要执行 cleanupcleanup 只会在 phase2 阶段触发,在 phase1 命中 cleanup,表示当前栈帧无法 catch 异常,会继续执行 unwind。1 和 4 的 lpad 也是 0 表示不存在执行 cleanup 的函数入口,2 不为 0,实际上也只有 Call Site 2 会在阶段 2 跳转到 Ltmp2 地址处执行 cleanup。

Call Site start(代码标签) len lpad action(索引)
Call Site 1 Lfunc_begin0 Ltmp0-Lfunc_begin0 no landing pad 0(cleanup)
Call Site 2 Ltmp0 Ltmp1-Ltmp0 jumps to Ltmp2 0
Call Site 3 Ltmp3 Ltmp4-Ltmp3 jumps to Ltmp5 5
Call Site 4 Ltmp4 Ltmp11-Ltmp4 no landing pad 0

抛出异常代码 throw std::out_of_range("out of range") 对应代码标签 Ltmp3

1
2
3
4
5
6
7
8
9
Ltmp3:
.loc 1 0 15 ; CPPException/test.cpp:0:15
ldr x0, [sp, #24] ; 8-byte Folded Reload
adrp x1, __ZTISt12out_of_range@GOTPAGE
ldr x1, [x1, __ZTISt12out_of_range@GOTPAGEOFF]
adrp x2, __ZNSt12out_of_rangeD1Ev@GOTPAGE
ldr x2, [x2, __ZNSt12out_of_rangeD1Ev@GOTPAGEOFF]
.loc 1 21 9 ; CPPException/test.cpp:21:9
bl ___cxa_throw // <<<<<<<< 在这里抛出异常

Ltmp3Call Site 3 范围内 。

Call Site 3 Ltmp3 Ltmp4-Ltmp3 Ltmp5 5

Call Site 3action 字段值为 5, 对应 Action Record 5:

Action Record 5 2 Continue to action 4

Action Record 5 使用 out_of_range 判断是否能 catch 异常。抛出异常类型为 out_of_range,action 5 可以 catch 异常,search 阶段执行完成。

Call Site 3 对应的 landing pad addressLtmp5,在 phase2 跳转到 Ltmp5 根据 type_info 判断具体执行的 catch 语句,Ltmp5 标签处的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Ltmp5:
.loc 1 27 1 ; CPPException/test.cpp:27:1
stur x0, [x29, #-8]
mov x8, x1
stur w8, [x29, #-12]
b LBB0_4
LBB0_4:
.loc 1 22 5 ; CPPException/test.cpp:22:5
ldur w8, [x29, #-12]
str w8, [sp, #12] ; 4-byte Folded Spill
subs w8, w8, #2
cset w8, ne
tbnz w8, #0, LBB0_8
b LBB0_5

LBB0_8 最终会执行第一个 catch 语句:

1
2
.loc  1 25 9                          ; CPPException/test.cpp:25:9
bl _printf

LBB0_5 最终会执行第二个 catch 语句:

1
2
.loc  1 23 9                          ; CPPException/test.cpp:23:9
bl _printf

源码分析

对 LSDA 的内存布局和异常处理流程有一定了解之后,再去阅读异常处理流程的源码,相对就比较容易了。接下来回到问题本身,从源码的角度分析一下在快手
App 内为什么 exception 无法 catch out_of_range

异常处理流程会先获取 catch 语句中 exceptiontype_info

1
2
3
4
5
const __shim_type_info* catchType =
get_shim_type_info(static_cast<uint64_t>(ttypeIndex),
classInfo, ttypeEncoding,
native_exception, unwind_exception,
base);

catchType == 0 表示 catch (...) 会捕获所有异常。不为 0 时调用 can_catch 方法判断 catch 块中声明的类型和抛出异常的类型是否匹配。

1
2
3
if (catchType->can_catch(excpType, adjustedPtr)) {

}

can_catch 有两个判断,其中任意一个成立都表示可以 catch 异常。

  1. 调用 is_equal 判断 catch 块类型是否和抛异常类型相等。
  2. 调用 has_unambiguous_public_base 判断两者是否有相同的 base,判断 base 是否相同时也会调用 is_equal 方法。(unambiguous: 明确的)。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    bool
    __class_type_info::can_catch(const __shim_type_info* thrown_type,
    void*& adjustedPtr) const
    {
    // bullet 1
    if (is_equal(this, thrown_type, false))
    return true;
    const __class_type_info* thrown_class_type =
    dynamic_cast<const __class_type_info*>(thrown_type);
    if (thrown_class_type == 0)
    return false;
    // bullet 2
    __dynamic_cast_info info = {thrown_class_type, 0, this, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,};
    info.number_of_dst_type = 1;
    thrown_class_type->has_unambiguous_public_base(&info, adjustedPtr, public_path);
    if (info.path_dst_ptr_to_static_ptr == public_path)
    {
    adjustedPtr = const_cast<void*>(info.dst_ptr_leading_to_static_ptr);
    return true;
    }
    return false;
    }
    is_equal 的判断逻辑如下,use_strcmp 传入的是 false,执行第 8 行:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    static inline
    bool
    is_equal(const std::type_info* x, const std::type_info* y, bool use_strcmp)
    {
    // Use std::type_info's default comparison unless we've explicitly asked
    // for strcmp.
    if (!use_strcmp) //
    return *x == *y;
    // Still allow pointer equality to short circut.
    return x == y || strcmp(x->name(), y->name()) == 0;
    }
    std::type_info 重载了 == 方法:
1
2
3
4
bool operator==(const type_info& __arg) const _NOEXCEPT
{
return __impl::__eq(__type_name, __arg.__type_name);
}

默认 __impl__eq 实现如下:

1
2
3
4
5
6
7
8
9
static bool __eq(__type_name_t __lhs, __type_name_t __rhs) _NOEXCEPT {
if (__lhs == __rhs)
return true;
if (__is_type_name_unique(__lhs) || __is_type_name_unique(__rhs))
// Either both are unique and have a different address, or one of them
// is unique and the other one isn't. In both cases they are unequal.
return false;
return __builtin_strcmp(__type_name_to_string(__lhs), __type_name_to_string(__rhs)) == 0;
}

结合源码信息, 再次回顾下我们这次遇到的问题,直接原因是 out_of_range 遍历到 base std::exception 时和 catch 语句声明的 std::exception 在执行 is_equal 时返回了 false,便于区分我们把前者称之为 thrown_exception, 后者称之为 catch_exception

第一个判断条件 ==

== 判断的是 type_info __type_name 的地址,__type_name 的类型是 const char *thrown_exception__type_name 存储在 libc++abi.dylib__TEXT.__const 段。catch_exception__type_name 存储在主可执行文件的 __TEXT.__const 段。所以地址判等为 false

第二个判断 is_unique

thrown_exceptiontype_infounique 类型的,unique 表示 type_info 在程序中只存在一份副本,因此对于地址不同的 type_info 一定是不相等的,不需要判断 name 是否相等, 所以在第二个 if 语句返回了 false。

第三个判断 strcmp:
虽然两个 type_infoname 都是 St9exception,但在第二个判断返回 false,并没有走到 strcmp 的逻辑里面。

对于 type_info 的判等,实际上存在三种方式:

1. __unique_impl::__eq

1
2
3
static bool __eq(__type_name_t __lhs, __type_name_t __rhs) _NOEXCEPT {
return __lhs == __rhs;
}

在遵循 Itanium ABI 的编译器中,对于给定类型的 RTTI,只有一个唯一的副本存在,因此可以通过比较类型名称的地址来判断 ,无需使用字符串,可以提高性能并简化代码。

2. __non_unique_impl::__eq

1
2
3
static bool __eq(__type_name_t __lhs, __type_name_t __rhs) _NOEXCEPT {
return __lhs == __rhs || __builtin_strcmp(__lhs, __rhs) == 0;
}

由于各种原因,链接器可能没有合并所有类型的 RTTI(例如:-Bsymbolic 或 llvm.org/PR37398)。在这种情况下,如果两个 type_info 的地址相等或者它们的名称字符串相等,这两个 type_info 被认为是相等的。

修复方案中的方案 5 通过设置 cflag 把 __impl 类型修改为 __no_unique_impl,使用 strcmp 方法判断主可执行文件中的 type_infolibc++abi 中的相等。

3.__non_unique_arm_rtti_bit_impl::__eq 默认实现

这种方式是 Apple ARM64 的特定实现,给定类型的 RTTI 可能存在多个副本。在构造 type_info 时,编译器将类型名称的指针存储在 uintptr_t 类型中,指针的最高位表示 non_unique 默认为 0(false)。如果最高位被设置为 1,表示 type_info 在程序中不是唯一的。如果最高位没有被设置,表示 type_info 是唯一的。

这个设计的目的是为了避免使用 weak 符号。它将原本会被作为弱符号生成的默认可见性的 type_info,转而使用隐藏可见性的 type_info,并把 non_unique bit 位设置为 1,表示非唯一。这样做的好处是,在链接镜像内,hidden 可见性的 type_info 仍然可以认为是唯一的,可以继续通过 linker 进行去重,而在不同的镜像间,会被视为不同的类型,避免了 weak 符号被重定向,导致 RTTI 类型信息混乱。

EH & -fno-rtti

这次问题的直接原因是主可执行文件多了一份 type_info, 那为什么禁用 RTTI 之后会重新生成一份 type_info 呢?

异常处理流程依赖 type_info 实现 can catch 的判断逻辑,在禁用 RTTI 之后,为了能继续获取异常类型的 type_info 信息,编译器会重新生成一份。

llvm ItaniumCXXABI.cpp 文件 BuildTypeInfo 方法内可以查看生成 type_info 的逻辑。

其中判断是否使用外部的 type_info 代码如下:

1
2
3
4
// Check if there is already an external RTTI descriptor for this type.
if (IsStandardLibraryRTTIDescriptor(Ty) ||
ShouldUseExternalRTTIDescriptor(CGM, Ty))
return GetAddrOfExternalRTTIDescriptor(Ty);

条件1: IsStandardLibraryRTTIDescriptor
判断是否是基础类型,比如 int bool float double。

条件2: ShouldUseExternalRTTIDescriptor
判断 type_info 是否已经存在于其他位置,如果是在当前的编译单元中就不需要再生成 type_info

ShouldUseExternalRTTIDescriptor 方法内判断了 RTTI 的状态,禁用后直接返回了 false。

1
2
3
// If RTTI is disabled, assume it might be disabled in the
// translation unit that defines any potential key function, too.
if (!Context.getLangOpts().RTTI) return false;

禁用 RTTI 后上述两个条件都不满足,会继续执行生成异常类型的 type_info,同时也会生成 base 的 type_info

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
  
///Record 表示 Structure/Class descriptor
case Type::Record: {
const CXXRecordDecl *RD =
cast<CXXRecordDecl>(cast<RecordType>(Ty)->getDecl());
if (!RD->hasDefinition() || !RD->getNumBases()) {
// We don't need to emit any fields.
break;
}

if (CanUseSingleInheritance(RD))
BuildSIClassTypeInfo(RD);
else
BuildVMIClassTypeInfo(RD);

break;
}

总结

开局 3 分钟,战至二塔下猥琐发育的鲁班自言自语到: 有人需要技术支持吗? 鲁班大师,智商二百五,膜拜,极度膜拜。

查看 mmkv 的 issue 列表,发现我们并不孤单。iOS 工程通常使用 cocoapods 集成不同的组件,这些组件在编译时会作为一个独立的 target,任意一个 target 的编译选项禁用 RTTI 后都会影响到宿主 App 的异常处理流程,继而可能引发 try catch 失效。

descript

为了解决此类问题,给大家提供两个规避方案:

  1. 禁用 RTTI 的同时禁用 exception handling,即 -fno-rtti-fno-exceptions 一起使用,这样单独的 target 不会影响到宿主 App 的 exception handing
  2. 如果禁用 RTTI 后想保留 exception handing,添加 cflag -D_LIBCPP_TYPEINFO_COMPARISON_IMPLEMENTATION=2,在损耗一点性能的前提下保证 exception handling 正常的处理流程。

参考资料

[1] https://llvm.org/docs/ExceptionHandling.html

[2] https://itanium-cxx-abi.github.io/cxx-abi/abi-eh.html

[3] https://itanium-cxx-abi.github.io/cxx-abi/exceptions.pdf

[4] http://www.hexblog.com/wp-content/uploads/2012/06/Recon-2012-Skochinsky-Compiler-Internals.pdf

[5] https://github.com/Tencent/MMKV/issues/744

[转载] QQ 9"傻快傻快"的?!带你看看背后的技术秘密

原文地址

最新发布的 QQ 9 自上线以来,流畅度方面收获了众多用户好评,不少用户戏称 QQ 9 “傻快傻快”的,快到”有点不习惯了都**”**。

作为庞大量级的应用,QQ 9 从哪些方面做了哪些优化,使得用户能够明显感觉到流畅度的提升?本文将详细介绍
QQ 9 流畅背后的技术实现,以及在全流程做的性能优化探索,为应用提升流畅度提供可复用的经验。

仍有5亿人坚持用 QQ

今年是中国开启互联网时代的第 30 年,也是 QQ 作为”初代互联网产品”的第25年,手机 QQ 的第 14 年。

#仍有5亿人坚持用 QQ # ,正是有这群用户的坚持,督促着 QQ 技术团队不断的自我革新,为了能给用户更好的体验,对性能孜孜不倦的追求。

descript

QQ 9 开始,我们从底层架构自底向上全部重构优化,解决了手机客户端原来启动缓慢、容易卡、转菊花等待时间长、UI
跳变等一系列问题。上线后,收获了用户众多好评,其中有个高频关键词是「丝滑」,在丝滑的背后,其实是技术人吹毛求疵般的打磨。

本文将为大家揭开 QQ 9 背后的技术探索,分享 QQ 匠人们硬核的优化手段。

吹毛求疵的打磨

极致秒开 — 启动速度优化

QQ 的丝滑体验从「启动优化」开始,以 iOS 端为例,启动流程主要分为 3
个阶段:

  1. T0:点击图标到 main 函数开始;
  2. T1:从 main 函数开始到 didFinishLaunchingWithOptions 结束;
  3. T2:didFinishLaunchingWithOptions 结束到首帧渲染完成。

一般将启动过程按阶段分为 pre-main (T0) 和 post-main (T1 + T2)
两个执行阶段:

  1. pre-main 阶段:系统 dyld 加载 App 镜像和初始化行为,与程序结构和规模关系较大。
  2. post-main 阶段:App 在渲染上屏前做的业务初始化行为,与具体业务逻辑关系较大。

一般工程上的优化方向:

  1. pre-main 阶段降低加载和链接的耗时:如动态链接转为静态链接,代码拆分组成动态库并进行懒加载。
  2. post-main 阶段减少主线程所执行的代码总量:如代码下架,代码执行时机延后或异步子线程化,代码逻辑执行效率优化等。

以下就这两个方向,介绍一下 QQ 本次做的有亮点的地方。

pre-main 阶段 - 按需装载代码

descript

动态库懒加载方案原理图

代码拆分组成动态库并进行懒加载这项技术多应用于业界大型 App(抖音、Facebook、快手)中,但 QQ
的业务复杂度颇高,直接使用业界方案无法满足我们的需求。经过一番探索我们找到了一些创新技术点:

  1. 使用 attribute((objc_runtime_visible))
  2. 实现低成本代码动态化改造。

使用 objc_setHook_getClass 实现动态化代码入口收敛,保证了方案稳定性。

最终在 QQ 9 中大规模的应用实现了对 pre-main 阶段的启动耗时优化(这个技术方案约贡献了33%左右的启动总耗时优化数据收益):

descript

post-main 阶段 - 线程治理

我们防劣化系统监控到主线程抢占的问题越来越严重,通过 Instruments
查看,我们发现一些严重的情况下,温启动过程中主线程有
14%的时间片处于被其他线程抢占的状态。

descript

什么是主线程抢占(Preempted)问题?简单来说就是主线程的 CPU 时间片被其他线程抢占,导致主线程得不到 CPU 资源。随着抢占问题越来越严重,也引出一些相关的问题,例如启动总耗时也随着劣化、启动后卡顿、启动耗时波动大、防劣化性能报告误判概率增大等。

为什么会出现主线程被抢占?简单来说有以下几个原因:

  1. 系统调度行为、系统级线程(比如 PageIn 线程)抢占。
  2. APP 频繁开辟子线程却不注意管理子线程的数量,可能会出现「线程爆炸」的情况,而且子线程不恰当地设置 QoS,会容易导致主线程被抢占。
  3. 主线程任务过重,占用时间片过长,会被系统惩罚降级,然后被其他子线程抢占。

了解了原因以后,我们从以下三个方面进行治理:

减少子线程的数量

手 Q 大部分业务广泛使用 GCD,经过查找资料和研究,我们发现频繁使用 GCD
的全局队列,可能会导致线程爆炸,原因是当子线程在 sleep/wait/lock
状态时,会被 GCD
认为是非活跃的状态,当有新的任务到来时可能便会创建新的线程。

descript
Apple 工程师、前 GCD 开发工程师发表言论

苹果官方建议不要创建大量队列,使用 target_queue 设置队列的层级结构,多个子系统就形成了一个队列的树状结构,最后队列底层使用串行队列作为 target_queue 。详情见《Modernizing Grand Central Dispatch Usage - WWDC17》

降低子线程 QoS

如果全局队列 QoS 设置为 DISPATCH_QUEUE_PRIORITY_DEFAULT ,则该任务的
QoS 将继承原来所在队列的 QoS (如果原来队列是主队列,将从 QOS_CLASS_USER_INTERACTIVE 降低为 QOS_CLASS_USER_INITIATED)。开发同学经常在主线程将任务派发到全局队列,并指定 QoS 为 DISPATCH_QUEUE_PRIORITY_DEFAULT ,这将导致存在大量子线程 QoS 为 QOS_CLASS_USER_INITIATED。以下是 QoS 优先级排序:

1
2
3
4
5
6
7
8
__QOS_ENUM(qos_class, unsigned int,
QOS_CLASS_USER_INTERACTIVE = 0x21, // 33
QOS_CLASS_USER_INITIATED = 0x19, // 25
QOS_CLASS_DEFAULT = 0x15, // 21
QOS_CLASS_UTILITY = 0x11, // 17
QOS_CLASS_BACKGROUND = 0x09, // 9
QOS_CLASS_UNSPECIFIED = 0x00, // 0
);

而实际开发中,很多网络请求、写磁盘 I/O,都使用了该 QoS,实际上是可以通过降低 QoS 来降低子线程的优先级。

提高主线程的优先级

QoS 并不完全等价于最终的线程优先级,主线程优先级范围为 29~47 。为什么运行过程中主线程优先级会变化?官方文档 《Mach Scheduling and Thread Interfaces》中的 “Why Did My Thread Priority Change? “ 章节解释了这个原因:如果线程的运行超出了其分配的时间而没有被阻塞,则会受到惩罚甚至被降低优先级,这么做的目的就是为了避免高优先级的线程一直抢占系统资源,导致低优先级的线程一直处于饥饿的状态。

如何避免主线程运行超出 CPU 分配时间,而免除降级惩罚?可以从 RunLoop 层面做减负。

App 启动过程开始的第一个 RunLoop,会执行持续到首屏渲染结束。而首屏的任务一般很重,导致 RunLoop 耗时很长,容易被系统降级。

descript
QQ 启动时第一个 Runloop 耗时示意图

解决方案是对第一个 RunLoop 里的任务做拆分。我们的做法是保留必要的全局初始化逻辑在第一个 RunLoop
中,把主 UI 的创建延迟到下一个 RunLoop 里。这样不仅有效地解决了启动时主线程被抢占的情况,还能够加速启动更快看到主页面。

其实这里还有一些优化空间,我们将第一个 RunLoop 的任务都挪到第二个 RunLoop 了,就又导致第二个 RunLoop 耗时较大,可以按照此思路继续优化。

“众”享丝滑 — 性能流畅度提升

如何定义流畅?

流畅(丝滑),体感上的表现是屏幕内容跟随手指操作即时变化,每一次操作都即时地反馈在屏幕上。如图所示,未开启高刷帧率时应保证 16.67ms 内将用户操作更新至屏幕上。

descript
用户每个操作,都需经历图中的4个步骤,任一步骤时间过长,都会无法及时更新画面造成卡顿。来源:《Advanced Graphics and Animation Performance》

让 App 做到每 16.67 毫秒更新一次用户操作很难吗?难,难在这么短的时间内 CPU 和 GPU 需要完成很多事情,更具体的:

屏幕上显示的内容只能在主线程更新(只能单核,无法利用到手机的多核 CPU)。

影响 GPU 的耗时因素多,展示的界面越复杂耗时越多。

主线程的 16.67 毫秒 - 系统需要的耗时 = 开发者可用的时间。如下图所示,蓝色区域为开发者占用的时间,当开发者使用的时间过长即会造成 hang,即卡顿。

descript

  • 紫色区域:系统接受与处理用户手势操作的耗时
  • 蓝色区域:开发者转换用户操作为屏幕显示内容的耗时
  • 黄色区域:屏幕展示内容的耗时
    来源:《Explore UI animation hitches and the render loop》

如此,想要丝滑就必须做到以下两点:

  1. 善用多线程编程,尽可能少在主线程上做更新 UI 以外的事情。
  2. 尽可能让 GPU 绘制简单的界面,减少 GPU 耗时。

善用多线程编程,尽可能少在主线程上做更新 UI 以外的事情。

NT 内核架构打好基础

QQ 9 所采用的 NT Kernel(NT:New Technology,此处向 Windows NT 内核致敬),基于尽可能发挥多核 CPU 能效的理念而诞生,如下图所示,最大程度将业务处理逻辑从负责 UI 展示的主线程中剥离,且使用异步调用代替线程锁,提升效率的同时降低死锁的可能。

descript
NT Kernel 多线程模型

此外,NT Kernel 采用 C++ 实现 IM 软件的核心基础能力,使其能跨平台使用,保证各平台的性能体验一致,用户交互界面则采用各平台原生语言实现。让用户感受强劲性能的同时保证了各平台特有的体验。

descript
NT Kernel 支持多平台架构图

全量刷新改增量刷新

在全新 NT 内核的加持下,耗时业务逻辑都已经挪到子线程,主线程仅剩刷新 UI 的相关工作。那刷新 UI 这个事儿还有进一步优化的空间吗?答案是肯定的,14 年陈的手机 QQ 在屏幕上更新一条新消息,会将当前展示的消息全部刷新一遍,即”全量刷新”机制。滚动时无法刷新消息、资源跳变等坏体验,都是该机制导致的。

为什么滚动时无法刷新消息?并非无法刷新,而是不能刷新。多余的刷新操作很容易使得 UI 更新无法在 16.67ms 内完成,进而诱发卡顿。

为什么会出现资源跳变?全量刷新会触使屏幕上的所有节点回收、重用,并且这种重用还是无序的。如下图所示,全量刷新后节点位置会随机发生改变,例如:尾号1b400(左图第2个)的节点刷新前用于展示2,刷新则展示7(右图第7个)。

descript

对比左右两张图的节点内存地址可见,全量刷新后会出现随机变化,并无规律可言。

无论是静态或是动态图片,都存在磁盘 I/O、解码等耗时操作,一般都会采用异步加载,避免主线程的卡顿。再叠加这种随机重用的特性,也就造成了”资源跳变”的表现。

根据不同的重用情况会有以下三种表现:

  1. 恰好是上次所用的节点或者内容恰好相同:相同内容赋值,没有任何变化。
  2. 没有相关动/静图:内容从无到有,符合预期。
  3. 有相关动/静图,但与当前 Model 的内容不一致:出现闪烁。如图下图所示。

descript
所有异步加载数据的元素搭配全量刷新,在未加载完毕前会展示其他节点的旧信息;即使刷新时重置视图也无法解决,只是从A->A->B改成A->空->B,依然存在明显的跳变。

QQ 9 采用的”增量刷新”就能很好的解决上述两个体验问题。此外,还有一个全量刷新无法实现的隐藏福利:节点动画,如下视频所示。

Jul-25-2024 14-29-16

实现增量刷新需要有个可靠的 Diff 算法,告知系统有变化的节点是需要执行刷新、插入、删除、移动中的哪种操作,一旦给到错误的信息将会直接导致 App Cras。敲定算法过程也是一波三折。

首先,阅读源码发现 Android 与 iOS 系统内置的 Diff 工具都是采用 Myers 算法实现的。

Myers:计算结果保存在changes的数组内,其中只有insert、remove两种类型。(来源:Swift
Diffing)

descript

Myers算法求解过程,通过插入、删除求源到目的的最短编辑距离。来源:AnO(ND)
difference algorithm and its variations

该算法在计算移动时存在”缺陷”,其通过插入+删除行为推测移动,特定场景下移动操作会降级为插入+删除。比如,先删除再移动就会转换为删除+插入,反之则是移动+删除:

  • 删 + 移 → 删 + 增:
    • 数据集A:[1, 2, 3, 4, 5]->数据集B:[2, 3, 5, 4]。会删除1、4,接着插入4。
  • 移 + 删 → 移 + 删:
    • 数据集A:[1, 2, 3, 4, 5]->数据集B:[1, 2, 4, 3]。会交换3、4,随后删除5。

经过分析,理想的 Diff 算法应该具有以下两种特质:

  • 能够记录节点之间的移动关系,并不是通过插入、删除的联系推断移动。
  • 具备较低的时间复杂度与空间复杂度。

对比行业方案后,选中论文《A technique for isolating differences
between files》中描述的 Heckel Diff
算法。该算法的最优、平均、最差复时间/空间复杂度均为 O(m+n),优于 Myers
算法的
O((m+n)*d)。其符号表的实现方式保证所有移动操作均被记录,不会再出现
Myers 中丢移动操作的情况,如下图所示。

descript
Heckel算法通过6个步骤借助符号表产生新老数据之间的Diff信息

  1. PASS1. 建立新数据所需新索引数组(NA)与 Symbol Table 之间的关系
  2. PASS2. 建立老数据所需旧索引数组(OA)与 Symbal Table 之间的关系。
  3. PASS3. 查找位置没有变化的节点,更新新旧索引数组(NA、OA)中的索引信息。
  4. PASS4 - PASS5:适用于对两个本文进行比较的 Case(存在 Key 值相同的情况),在 QQ 的应用场景中不允许出现相同 Key 值的情况,可跳过。感兴趣的同学可以直接查阅论文。
  5. PASS6. 根据现有结果计算差异,如图下图所示:
    descript
    D表示被删除,U表示没有变化,4、5之间存在移动关系。

那么 Heckel 算法是完美的吗?不然,它并没有考虑冗余的移动信息,冗余的移动操作会导致下图中的动画错乱问题。

descript

我们在 Heckel 算法的基础上进行改良优化,追踪记录移动操作,区分出直接移动与间接移动,并将间接移动部分进行过滤删除,最终得到满足 QQ 9 各项指标要求的 Diff 算法。如下图示例,ID5 直接移动到第一行,ID1-4 都是间接往下移动。

descript

记录直接移动的偏移量(move = insert X + delete Y的偏移量都需要记录),修正间接/被动移动的结果(ID 1-4的移动)

并行预布局

异步布局作为业界的最佳实践,自然不能在 QQ 9 上缺席。我们也进一步尝试将异步布局并行化,深挖性能极限。

首先尝试了 N 条消息 N 个线程的方案:用 GCD 派发 N 个并发任务,然后用 DispatchGroup 等待这些任务执行完成。通过并行预布局,将原本一个线程需要几十毫秒的预布局减少到了十几毫秒。这个方案后来发现了
2 个问题:

并行布局 N 条消息的总耗时还是比串行布局一条消息的耗时要大得多,受限于 CPU 核心数,代码中的锁或其他资源竞争导致 N 条消息的参数准备和布局计算没有能充分的并行。

这N条消息的布局任务分别和 N 个 GCD 任务一对一绑定了,GCD 调度这 N 个任务中有任何一个调度慢都会拉长整个预布局的耗时。

descript

充分利用多核CPU的算力;使用并行计算,布局计算的总耗时减少了约76%。

调整后的方案如上图所示,使用了 M 个执行者来执行N条消息的布局任务(N>=M>0)。当前线程(异步布局主线程)来执行 1 个执行者,然后再由 GCD 额外调度(M-1)个线程来执行(M-1)个执行者。 首先将待计算的消息放入一个队列中,每个执行者都会循环从待计算的消息队列中取出一条消息执行布局计算,直到待计算的消息队列为空。因为消息的布局任务没有和任何一个执行者绑定,即使有执行者较长时间没有被调度也不会导致布局计算迟迟无法完成,大部分情况下这 M 个执行者会被 M 个线程并行执行。

descript

并行布局的总耗时会随着并发线程的增加而减少,当增加到5以后耗时就基本没有怎么减少了。

看上去目前布局计算的工作已经从主线程挪走了,现实是很多时候计算出来的坐标与大小并没有与屏幕的像素点大小吻合,此时系统会在主线程再做一次”像素对齐”。在”异步布局”时也不能忽略该细节,才能确确实实减少主线程的负担,如下图所示。

descript

OLED屏幕的1个像素R:G:B比例为1:2:1,显示时DDIC(Display DriverIC,显示驱动芯片)会进行次像素渲染从其他像素借元素使显示更饱满。但代码并不能直接控制该行为,系统需要保证提交的内容与屏幕像素完全对齐,即不能出现类似使用0.5个像素的情况。

  

descript
标黄区域为坐标、大小结果与屏幕像素未对齐

其他的优化还有:智能预加载、消息回收、图片资源异步解码等。如下图所示,根据屏幕比例得到一级缓存
display ,二级缓存 preload ,超出的部分则被回收释放。

descript
资源预加载策略图

尽可能让 GPU 绘制简单的界面,减少 GPU 耗时。

除了布局可以异步计算,复杂的图像也能使用”异步渲染”的方式降低 GPU 的耗时;特别是面对需要叠加裁剪的图形时, GPU 的绘制任务无法在一个 Frame 内完成,就需要再额外开辟一个 Frame Buffer 进行绘制,并在全部完成后将两个 Buffer 的内容进行合成,这被称作”离屏渲染”。离屏渲染对于性能的损耗非常大,主要在于 GPU 的上下文切换所需的开销很大,需要清空当前的管线和栅栏。原话在这:A Performance-minded take on iOS design | Lobsters。对于这种情况,苹果的工程师给出的建议是用 CPU 绘制来给 GPU
分担一部分工作。如下图所示:

descript
标黄区域为GPU离屏渲染,不可否认GPU的off-screen比CPU的off-screen代价高很多;在无法避免mask的场景下,使用多核CPU进行异步渲染性能更好。

我们在渲染消息时利用了多核 CPU 进行异步渲染,降低 GPU 部分的耗时。这里面临的难点在于:在可快速滑动更新的列表场景使用时会出现”闪白”的问题;如著名第三方开源框架 YYKit 也存在此类问题,我们通过 LRU 缓存+增量刷新的方式很好的解决了此问题。

叠满 buffer 的丝滑体验

基于上述 CPU 与 GPU 维度的各项优化,我们在消息 Tab 上实现了国内头部同类应用目前也不具备的滚动中实时接收消息的能力,且不会出现卡顿;此外,也扩展了老版本 150 个会话的限制,与聊天界面一致以分页的形式加载用户所有的会话节点,如下所示:

Jul-25-2024 14-26-09
滚动中接受消息,且不卡顿

进入群、好友聊天界面的速度也得到了质的提升,在加快进入动画的同时,依然能够保证即刻就能看到最新的聊天内容。如下图所示 —— 同一个帐号进入同一个聊天页面。左边是优化前的效果,聊天页面都快全部展示了,内容还在加载中;右边是优化后效果,聊天页面只展示了一点点,就已经能看到发送方头像和消息内容了。

descript

进入聊天页面加载速度对比图(左为优化前,右为优化后)

除了进入速度的提升,聊天内容翻页的速度也达到了业内顶尖水平:超越国内头部同类应用,对标 Telegram。不论用户有多少消息,都能够通过不断上拉看到,并且用户感知不到 loading 态。

descript

descript

聊天页面优化前后对比图(上为优化前,下为优化后)

青春常在 — 防劣化系统

打江山易,守江山难。防劣化是所有达到一定规模的技术团队都会头疼的问题,面对复杂的业务和技术债,手 Q 团队投入了 3 年的时间迭代优化,现在手 Q 的防劣化系统已经达到了业界先进水平。作为手 Q 质量的守门员,我们将其命名为 Hodor(Hold the door)。

防劣化目标:提前发现部分主路径问题,通过门禁防止性能劣化。

  1. 主干合流门禁:对于较稳定的性能指标,合流前自动检查。
  2. 日常自动提单:针对偶现的性能问题,开发阶段提前发现。
  3. 性能数据看板:常态化详细数据看板,上帝视角观测性能。
  4. 告警机器人:自定义各性能维度告警规则,第一时间反馈问题。

整体方案是基于 Instruments 动态追踪技术采集 diagnostic 诊断数据;xctrace 自动解析 trace 文件,翻译堆栈精准归因;每次提交构建均执行防劣化检测,精准定位问题;还有数据可视化看板 + 自动提单派发,将质量左移到开发阶段。最终实现了性能报告、数据分析、智能调度、提单告警、设备管理、用例管理等一系列能力。一图以蔽之:

descript
防劣化系统方案简介

Xcode 12 开始提供了 xctrace,其 Release Notes 中解决的很多 issue 也来自于手 Q 团队在防劣化开发过程中发现与反馈。在性能优化方面 QQ 与 Apple 性能团队交流紧密,大家也会加班克服中美时差。

整个手 Q 防劣化系统上线以来,有效地保证了开发主干的稳定性,也检测到了大量的性能和崩溃问题,同时拦住了很多新需求引入的性能问题。

descript
防劣化成果图

目前 Hodor 已经覆盖数十个场景,并落地 iOS/Android/Windows/macOS/Linux 五个平台。

轻盈焕新的 QQ 9

经过上述全方位优化,QQ 9 在各场景的性能都较历史版本较大的提升,如下图所示:

descript

使用苹果官方的工具:Xcode Organizer 可以看到 QQ 9在流畅度上较之前的版本 50 分位提升35%,卡顿率降低48%,启动耗时降低40%。如下图所示。

descript

总结和展望

本文我们介绍了 QQ 9 丝滑背后的技术实现,从启动速度,页面刷新,差异算法,预加载和回收,异步布局和渲染等方面介绍了我们在性能方面做的全流程优化,并介绍了几个用户体验提升的场景表现。

其实技术领域深入复杂,每一项优化点都可以单独拎出来好好地展开说明,因为篇幅问题,只能留到以后慢慢和大家分享。

希望 QQ 技术团队做的这些打磨,可以给用户带来切实的体验提升;也希望 QQ 能越来越好,因为我们每一位也是坚持使用 QQ 的 5 亿分之一。

-End-

[转载] 彻底弄懂 Linux 下的文件描述符(fd)

原文地址

最新发布的 QQ 9 自上线以来,流畅度方面收获了众多用户好评,不少用户戏称 QQ 9 “傻快傻快”的,快到”有点不习惯了都**”**。

作为庞大量级的应用,QQ 9 从哪些方面做了哪些优化,使得用户能够明显感觉到流畅度的提升?本文将详细介绍
QQ 9 流畅背后的技术实现,以及在全流程做的性能优化探索,为应用提升流畅度提供可复用的经验。

仍有5亿人坚持用 QQ

今年是中国开启互联网时代的第 30 年,也是 QQ 作为”初代互联网产品”的第25年,手机 QQ 的第 14 年。

#仍有5亿人坚持用 QQ # ,正是有这群用户的坚持,督促着 QQ 技术团队不断的自我革新,为了能给用户更好的体验,对性能孜孜不倦的追求。

descript

QQ 9 开始,我们从底层架构自底向上全部重构优化,解决了手机客户端原来启动缓慢、容易卡、转菊花等待时间长、UI
跳变等一系列问题。上线后,收获了用户众多好评,其中有个高频关键词是「丝滑」,在丝滑的背后,其实是技术人吹毛求疵般的打磨。

本文将为大家揭开 QQ 9 背后的技术探索,分享 QQ 匠人们硬核的优化手段。

吹毛求疵的打磨

极致秒开 — 启动速度优化

QQ 的丝滑体验从「启动优化」开始,以 iOS 端为例,启动流程主要分为 3
个阶段:

  1. T0:点击图标到 main 函数开始;
  2. T1:从 main 函数开始到 didFinishLaunchingWithOptions 结束;
  3. T2:didFinishLaunchingWithOptions 结束到首帧渲染完成。

一般将启动过程按阶段分为 pre-main (T0) 和 post-main (T1 + T2)
两个执行阶段:

  1. pre-main 阶段:系统 dyld 加载 App 镜像和初始化行为,与程序结构和规模关系较大。
  2. post-main 阶段:App 在渲染上屏前做的业务初始化行为,与具体业务逻辑关系较大。

一般工程上的优化方向:

  1. pre-main 阶段降低加载和链接的耗时:如动态链接转为静态链接,代码拆分组成动态库并进行懒加载。
  2. post-main 阶段减少主线程所执行的代码总量:如代码下架,代码执行时机延后或异步子线程化,代码逻辑执行效率优化等。

以下就这两个方向,介绍一下 QQ 本次做的有亮点的地方。

pre-main 阶段 - 按需装载代码

descript

动态库懒加载方案原理图

代码拆分组成动态库并进行懒加载这项技术多应用于业界大型 App(抖音、Facebook、快手)中,但 QQ
的业务复杂度颇高,直接使用业界方案无法满足我们的需求。经过一番探索我们找到了一些创新技术点:

  1. 使用 attribute((objc_runtime_visible))
  2. 实现低成本代码动态化改造。

使用 objc_setHook_getClass 实现动态化代码入口收敛,保证了方案稳定性。

最终在 QQ 9 中大规模的应用实现了对 pre-main 阶段的启动耗时优化(这个技术方案约贡献了33%左右的启动总耗时优化数据收益):

descript

post-main 阶段 - 线程治理

我们防劣化系统监控到主线程抢占的问题越来越严重,通过 Instruments
查看,我们发现一些严重的情况下,温启动过程中主线程有
14%的时间片处于被其他线程抢占的状态。

descript

什么是主线程抢占(Preempted)问题?简单来说就是主线程的 CPU 时间片被其他线程抢占,导致主线程得不到 CPU 资源。随着抢占问题越来越严重,也引出一些相关的问题,例如启动总耗时也随着劣化、启动后卡顿、启动耗时波动大、防劣化性能报告误判概率增大等。

为什么会出现主线程被抢占?简单来说有以下几个原因:

  1. 系统调度行为、系统级线程(比如 PageIn 线程)抢占。
  2. APP 频繁开辟子线程却不注意管理子线程的数量,可能会出现「线程爆炸」的情况,而且子线程不恰当地设置 QoS,会容易导致主线程被抢占。
  3. 主线程任务过重,占用时间片过长,会被系统惩罚降级,然后被其他子线程抢占。

了解了原因以后,我们从以下三个方面进行治理:

减少子线程的数量

手 Q 大部分业务广泛使用 GCD,经过查找资料和研究,我们发现频繁使用 GCD
的全局队列,可能会导致线程爆炸,原因是当子线程在 sleep/wait/lock
状态时,会被 GCD
认为是非活跃的状态,当有新的任务到来时可能便会创建新的线程。

descript
Apple 工程师、前 GCD 开发工程师发表言论

苹果官方建议不要创建大量队列,使用 target_queue 设置队列的层级结构,多个子系统就形成了一个队列的树状结构,最后队列底层使用串行队列作为 target_queue 。详情见《Modernizing Grand Central Dispatch Usage - WWDC17》

降低子线程 QoS

如果全局队列 QoS 设置为 DISPATCH_QUEUE_PRIORITY_DEFAULT ,则该任务的
QoS 将继承原来所在队列的 QoS (如果原来队列是主队列,将从 QOS_CLASS_USER_INTERACTIVE 降低为 QOS_CLASS_USER_INITIATED)。开发同学经常在主线程将任务派发到全局队列,并指定 QoS 为 DISPATCH_QUEUE_PRIORITY_DEFAULT ,这将导致存在大量子线程 QoS 为 QOS_CLASS_USER_INITIATED。以下是 QoS 优先级排序:

1
2
3
4
5
6
7
8
__QOS_ENUM(qos_class, unsigned int,
QOS_CLASS_USER_INTERACTIVE = 0x21, // 33
QOS_CLASS_USER_INITIATED = 0x19, // 25
QOS_CLASS_DEFAULT = 0x15, // 21
QOS_CLASS_UTILITY = 0x11, // 17
QOS_CLASS_BACKGROUND = 0x09, // 9
QOS_CLASS_UNSPECIFIED = 0x00, // 0
);

而实际开发中,很多网络请求、写磁盘 I/O,都使用了该 QoS,实际上是可以通过降低 QoS 来降低子线程的优先级。

提高主线程的优先级

QoS 并不完全等价于最终的线程优先级,主线程优先级范围为 29~47 。为什么运行过程中主线程优先级会变化?官方文档 《Mach Scheduling and Thread Interfaces》中的 “Why Did My Thread Priority Change? “ 章节解释了这个原因:如果线程的运行超出了其分配的时间而没有被阻塞,则会受到惩罚甚至被降低优先级,这么做的目的就是为了避免高优先级的线程一直抢占系统资源,导致低优先级的线程一直处于饥饿的状态。

如何避免主线程运行超出 CPU 分配时间,而免除降级惩罚?可以从 RunLoop 层面做减负。

App 启动过程开始的第一个 RunLoop,会执行持续到首屏渲染结束。而首屏的任务一般很重,导致 RunLoop 耗时很长,容易被系统降级。

descript
QQ 启动时第一个 Runloop 耗时示意图

解决方案是对第一个 RunLoop 里的任务做拆分。我们的做法是保留必要的全局初始化逻辑在第一个 RunLoop
中,把主 UI 的创建延迟到下一个 RunLoop 里。这样不仅有效地解决了启动时主线程被抢占的情况,还能够加速启动更快看到主页面。

其实这里还有一些优化空间,我们将第一个 RunLoop 的任务都挪到第二个 RunLoop 了,就又导致第二个 RunLoop 耗时较大,可以按照此思路继续优化。

“众”享丝滑 — 性能流畅度提升

如何定义流畅?

流畅(丝滑),体感上的表现是屏幕内容跟随手指操作即时变化,每一次操作都即时地反馈在屏幕上。如图所示,未开启高刷帧率时应保证 16.67ms 内将用户操作更新至屏幕上。

descript
用户每个操作,都需经历图中的4个步骤,任一步骤时间过长,都会无法及时更新画面造成卡顿。来源:《Advanced Graphics and Animation Performance》

让 App 做到每 16.67 毫秒更新一次用户操作很难吗?难,难在这么短的时间内 CPU 和 GPU 需要完成很多事情,更具体的:

屏幕上显示的内容只能在主线程更新(只能单核,无法利用到手机的多核 CPU)。

影响 GPU 的耗时因素多,展示的界面越复杂耗时越多。

主线程的 16.67 毫秒 - 系统需要的耗时 = 开发者可用的时间。如下图所示,蓝色区域为开发者占用的时间,当开发者使用的时间过长即会造成 hang,即卡顿。

descript

  • 紫色区域:系统接受与处理用户手势操作的耗时
  • 蓝色区域:开发者转换用户操作为屏幕显示内容的耗时
  • 黄色区域:屏幕展示内容的耗时
    来源:《Explore UI animation hitches and the render loop》

如此,想要丝滑就必须做到以下两点:

  1. 善用多线程编程,尽可能少在主线程上做更新 UI 以外的事情。
  2. 尽可能让 GPU 绘制简单的界面,减少 GPU 耗时。

善用多线程编程,尽可能少在主线程上做更新 UI 以外的事情。

NT 内核架构打好基础

QQ 9 所采用的 NT Kernel(NT:New Technology,此处向 Windows NT 内核致敬),基于尽可能发挥多核 CPU 能效的理念而诞生,如下图所示,最大程度将业务处理逻辑从负责 UI 展示的主线程中剥离,且使用异步调用代替线程锁,提升效率的同时降低死锁的可能。

descript
NT Kernel 多线程模型

此外,NT Kernel 采用 C++ 实现 IM 软件的核心基础能力,使其能跨平台使用,保证各平台的性能体验一致,用户交互界面则采用各平台原生语言实现。让用户感受强劲性能的同时保证了各平台特有的体验。

descript
NT Kernel 支持多平台架构图

全量刷新改增量刷新

在全新 NT 内核的加持下,耗时业务逻辑都已经挪到子线程,主线程仅剩刷新 UI 的相关工作。那刷新 UI 这个事儿还有进一步优化的空间吗?答案是肯定的,14 年陈的手机 QQ 在屏幕上更新一条新消息,会将当前展示的消息全部刷新一遍,即”全量刷新”机制。滚动时无法刷新消息、资源跳变等坏体验,都是该机制导致的。

为什么滚动时无法刷新消息?并非无法刷新,而是不能刷新。多余的刷新操作很容易使得 UI 更新无法在 16.67ms 内完成,进而诱发卡顿。

为什么会出现资源跳变?全量刷新会触使屏幕上的所有节点回收、重用,并且这种重用还是无序的。如下图所示,全量刷新后节点位置会随机发生改变,例如:尾号1b400(左图第2个)的节点刷新前用于展示2,刷新则展示7(右图第7个)。

descript

对比左右两张图的节点内存地址可见,全量刷新后会出现随机变化,并无规律可言。

无论是静态或是动态图片,都存在磁盘 I/O、解码等耗时操作,一般都会采用异步加载,避免主线程的卡顿。再叠加这种随机重用的特性,也就造成了”资源跳变”的表现。

根据不同的重用情况会有以下三种表现:

  1. 恰好是上次所用的节点或者内容恰好相同:相同内容赋值,没有任何变化。
  2. 没有相关动/静图:内容从无到有,符合预期。
  3. 有相关动/静图,但与当前 Model 的内容不一致:出现闪烁。如图下图所示。

descript
所有异步加载数据的元素搭配全量刷新,在未加载完毕前会展示其他节点的旧信息;即使刷新时重置视图也无法解决,只是从A->A->B改成A->空->B,依然存在明显的跳变。

QQ 9 采用的”增量刷新”就能很好的解决上述两个体验问题。此外,还有一个全量刷新无法实现的隐藏福利:节点动画,如下视频所示。

Jul-25-2024 14-29-16

实现增量刷新需要有个可靠的 Diff 算法,告知系统有变化的节点是需要执行刷新、插入、删除、移动中的哪种操作,一旦给到错误的信息将会直接导致 App Cras。敲定算法过程也是一波三折。

首先,阅读源码发现 Android 与 iOS 系统内置的 Diff 工具都是采用 Myers 算法实现的。

Myers:计算结果保存在changes的数组内,其中只有insert、remove两种类型。(来源:Swift
Diffing)

descript

Myers算法求解过程,通过插入、删除求源到目的的最短编辑距离。来源:AnO(ND)
difference algorithm and its variations

该算法在计算移动时存在”缺陷”,其通过插入+删除行为推测移动,特定场景下移动操作会降级为插入+删除。比如,先删除再移动就会转换为删除+插入,反之则是移动+删除:

  • 删 + 移 → 删 + 增:
    • 数据集A:[1, 2, 3, 4, 5]->数据集B:[2, 3, 5, 4]。会删除1、4,接着插入4。
  • 移 + 删 → 移 + 删:
    • 数据集A:[1, 2, 3, 4, 5]->数据集B:[1, 2, 4, 3]。会交换3、4,随后删除5。

经过分析,理想的 Diff 算法应该具有以下两种特质:

  • 能够记录节点之间的移动关系,并不是通过插入、删除的联系推断移动。
  • 具备较低的时间复杂度与空间复杂度。

对比行业方案后,选中论文《A technique for isolating differences
between files》中描述的 Heckel Diff
算法。该算法的最优、平均、最差复时间/空间复杂度均为 O(m+n),优于 Myers
算法的
O((m+n)*d)。其符号表的实现方式保证所有移动操作均被记录,不会再出现
Myers 中丢移动操作的情况,如下图所示。

descript
Heckel算法通过6个步骤借助符号表产生新老数据之间的Diff信息

  1. PASS1. 建立新数据所需新索引数组(NA)与 Symbol Table 之间的关系
  2. PASS2. 建立老数据所需旧索引数组(OA)与 Symbal Table 之间的关系。
  3. PASS3. 查找位置没有变化的节点,更新新旧索引数组(NA、OA)中的索引信息。
  4. PASS4 - PASS5:适用于对两个本文进行比较的 Case(存在 Key 值相同的情况),在 QQ 的应用场景中不允许出现相同 Key 值的情况,可跳过。感兴趣的同学可以直接查阅论文。
  5. PASS6. 根据现有结果计算差异,如图下图所示:
    descript
    D表示被删除,U表示没有变化,4、5之间存在移动关系。

那么 Heckel 算法是完美的吗?不然,它并没有考虑冗余的移动信息,冗余的移动操作会导致下图中的动画错乱问题。

descript

我们在 Heckel 算法的基础上进行改良优化,追踪记录移动操作,区分出直接移动与间接移动,并将间接移动部分进行过滤删除,最终得到满足 QQ 9 各项指标要求的 Diff 算法。如下图示例,ID5 直接移动到第一行,ID1-4 都是间接往下移动。

descript

记录直接移动的偏移量(move = insert X + delete Y的偏移量都需要记录),修正间接/被动移动的结果(ID 1-4的移动)

并行预布局

异步布局作为业界的最佳实践,自然不能在 QQ 9 上缺席。我们也进一步尝试将异步布局并行化,深挖性能极限。

首先尝试了 N 条消息 N 个线程的方案:用 GCD 派发 N 个并发任务,然后用 DispatchGroup 等待这些任务执行完成。通过并行预布局,将原本一个线程需要几十毫秒的预布局减少到了十几毫秒。这个方案后来发现了
2 个问题:

并行布局 N 条消息的总耗时还是比串行布局一条消息的耗时要大得多,受限于 CPU 核心数,代码中的锁或其他资源竞争导致 N 条消息的参数准备和布局计算没有能充分的并行。

这N条消息的布局任务分别和 N 个 GCD 任务一对一绑定了,GCD 调度这 N 个任务中有任何一个调度慢都会拉长整个预布局的耗时。

descript

充分利用多核CPU的算力;使用并行计算,布局计算的总耗时减少了约76%。

调整后的方案如上图所示,使用了 M 个执行者来执行N条消息的布局任务(N>=M>0)。当前线程(异步布局主线程)来执行 1 个执行者,然后再由 GCD 额外调度(M-1)个线程来执行(M-1)个执行者。 首先将待计算的消息放入一个队列中,每个执行者都会循环从待计算的消息队列中取出一条消息执行布局计算,直到待计算的消息队列为空。因为消息的布局任务没有和任何一个执行者绑定,即使有执行者较长时间没有被调度也不会导致布局计算迟迟无法完成,大部分情况下这 M 个执行者会被 M 个线程并行执行。

descript

并行布局的总耗时会随着并发线程的增加而减少,当增加到5以后耗时就基本没有怎么减少了。

看上去目前布局计算的工作已经从主线程挪走了,现实是很多时候计算出来的坐标与大小并没有与屏幕的像素点大小吻合,此时系统会在主线程再做一次”像素对齐”。在”异步布局”时也不能忽略该细节,才能确确实实减少主线程的负担,如下图所示。

descript

OLED屏幕的1个像素R:G:B比例为1:2:1,显示时DDIC(Display DriverIC,显示驱动芯片)会进行次像素渲染从其他像素借元素使显示更饱满。但代码并不能直接控制该行为,系统需要保证提交的内容与屏幕像素完全对齐,即不能出现类似使用0.5个像素的情况。

  

descript
标黄区域为坐标、大小结果与屏幕像素未对齐

其他的优化还有:智能预加载、消息回收、图片资源异步解码等。如下图所示,根据屏幕比例得到一级缓存
display ,二级缓存 preload ,超出的部分则被回收释放。

descript
资源预加载策略图

尽可能让 GPU 绘制简单的界面,减少 GPU 耗时。

除了布局可以异步计算,复杂的图像也能使用”异步渲染”的方式降低 GPU 的耗时;特别是面对需要叠加裁剪的图形时, GPU 的绘制任务无法在一个 Frame 内完成,就需要再额外开辟一个 Frame Buffer 进行绘制,并在全部完成后将两个 Buffer 的内容进行合成,这被称作”离屏渲染”。离屏渲染对于性能的损耗非常大,主要在于 GPU 的上下文切换所需的开销很大,需要清空当前的管线和栅栏。原话在这:A Performance-minded take on iOS design | Lobsters。对于这种情况,苹果的工程师给出的建议是用 CPU 绘制来给 GPU
分担一部分工作。如下图所示:

descript
标黄区域为GPU离屏渲染,不可否认GPU的off-screen比CPU的off-screen代价高很多;在无法避免mask的场景下,使用多核CPU进行异步渲染性能更好。

我们在渲染消息时利用了多核 CPU 进行异步渲染,降低 GPU 部分的耗时。这里面临的难点在于:在可快速滑动更新的列表场景使用时会出现”闪白”的问题;如著名第三方开源框架 YYKit 也存在此类问题,我们通过 LRU 缓存+增量刷新的方式很好的解决了此问题。

叠满 buffer 的丝滑体验

基于上述 CPU 与 GPU 维度的各项优化,我们在消息 Tab 上实现了国内头部同类应用目前也不具备的滚动中实时接收消息的能力,且不会出现卡顿;此外,也扩展了老版本 150 个会话的限制,与聊天界面一致以分页的形式加载用户所有的会话节点,如下所示:

Jul-25-2024 14-26-09
滚动中接受消息,且不卡顿

进入群、好友聊天界面的速度也得到了质的提升,在加快进入动画的同时,依然能够保证即刻就能看到最新的聊天内容。如下图所示 —— 同一个帐号进入同一个聊天页面。左边是优化前的效果,聊天页面都快全部展示了,内容还在加载中;右边是优化后效果,聊天页面只展示了一点点,就已经能看到发送方头像和消息内容了。

descript

进入聊天页面加载速度对比图(左为优化前,右为优化后)

除了进入速度的提升,聊天内容翻页的速度也达到了业内顶尖水平:超越国内头部同类应用,对标 Telegram。不论用户有多少消息,都能够通过不断上拉看到,并且用户感知不到 loading 态。

descript

descript

聊天页面优化前后对比图(上为优化前,下为优化后)

青春常在 — 防劣化系统

打江山易,守江山难。防劣化是所有达到一定规模的技术团队都会头疼的问题,面对复杂的业务和技术债,手 Q 团队投入了 3 年的时间迭代优化,现在手 Q 的防劣化系统已经达到了业界先进水平。作为手 Q 质量的守门员,我们将其命名为 Hodor(Hold the door)。

防劣化目标:提前发现部分主路径问题,通过门禁防止性能劣化。

  1. 主干合流门禁:对于较稳定的性能指标,合流前自动检查。
  2. 日常自动提单:针对偶现的性能问题,开发阶段提前发现。
  3. 性能数据看板:常态化详细数据看板,上帝视角观测性能。
  4. 告警机器人:自定义各性能维度告警规则,第一时间反馈问题。

整体方案是基于 Instruments 动态追踪技术采集 diagnostic 诊断数据;xctrace 自动解析 trace 文件,翻译堆栈精准归因;每次提交构建均执行防劣化检测,精准定位问题;还有数据可视化看板 + 自动提单派发,将质量左移到开发阶段。最终实现了性能报告、数据分析、智能调度、提单告警、设备管理、用例管理等一系列能力。一图以蔽之:

descript
防劣化系统方案简介

Xcode 12 开始提供了 xctrace,其 Release Notes 中解决的很多 issue 也来自于手 Q 团队在防劣化开发过程中发现与反馈。在性能优化方面 QQ 与 Apple 性能团队交流紧密,大家也会加班克服中美时差。

整个手 Q 防劣化系统上线以来,有效地保证了开发主干的稳定性,也检测到了大量的性能和崩溃问题,同时拦住了很多新需求引入的性能问题。

descript
防劣化成果图

目前 Hodor 已经覆盖数十个场景,并落地 iOS/Android/Windows/macOS/Linux 五个平台。

轻盈焕新的 QQ 9

经过上述全方位优化,QQ 9 在各场景的性能都较历史版本较大的提升,如下图所示:

descript

使用苹果官方的工具:Xcode Organizer 可以看到 QQ 9在流畅度上较之前的版本 50 分位提升35%,卡顿率降低48%,启动耗时降低40%。如下图所示。

descript

总结和展望

本文我们介绍了 QQ 9 丝滑背后的技术实现,从启动速度,页面刷新,差异算法,预加载和回收,异步布局和渲染等方面介绍了我们在性能方面做的全流程优化,并介绍了几个用户体验提升的场景表现。

其实技术领域深入复杂,每一项优化点都可以单独拎出来好好地展开说明,因为篇幅问题,只能留到以后慢慢和大家分享。

希望 QQ 技术团队做的这些打磨,可以给用户带来切实的体验提升;也希望 QQ 能越来越好,因为我们每一位也是坚持使用 QQ 的 5 亿分之一。

-End-

[转载] JIT真的比解释执行快么------关于JS引擎的一些热门话题

[转载] JIT真的比解释执行快么——关于JS引擎的一些热门话题

原文地址

在编程语言的世界中,如何高效地执行代码一直是一个热门话题。随着脚本语言的普及和性能需求的提升,解释执行和即时编译(JIT)成为了两种常见的代码执行方式。本文探讨了这两种技术,通过详细的实例和深入的分析,为我们揭示了它们的工作原理、性能差异以及各自的优缺点。

希望这篇文章能够帮助你更好地理解编程语言执行的技术世界,激发你对高效代码执行的深入思考,并在实践中应用这些宝贵的知识。

什么是JIT和解释执行

要解释什么是JIT,什么是解释执行,我们来看一个简单的例子,就很好理解了:

descript

对于一个语言,一定有一套规定好的行为。执行这个语言编写的程序,就是按照规定好的行为一行一行逐步生效的过程。C语言有这样一套规定,比如 a=b+c; 就代表了:

  1. 取出变量b内存中的数字
  2. 取出变量c内存中的数字
  3. 相加
  4. 结果放到变量a里

C语言的”规定”本身比较简单,由于强类型,其行为上也贴近机器码的行为。我们通过编译把C代码转换成机器码后,汇编代码和C代码之间的对应关系还是比较清晰的。

但是对于一些更加现代的脚本语言来说,规定就很复杂了。同样以 a = b + c 举例,在JS中,规定可能是这样的:

  1. 取出变量b内存中的数字
    1. 变量b是一个闭包, 那么要xxxx
    2. 变量b如果是一个局部变量, 那么要xxxx
    3. 变量b如果是一个全局变量, 那么要xxxx
    4. 变量b如果不存在, 那么要xxxx
    5. ….
  2. 取出变量c内存中的数字
    1. …. (同上)
  3. 相加
    1. 相加的值如果都是数字类型, 那么xxxx
    2. 相加的值如果都是字符串类型,那么xxxxx
    3. 相加的值如果是xxxxx, 那么xxxxx
    4. ….
  4. (如果相加过程抛出了异常)
    1. 如果异常有catch block, 那么xxxxx
    2. 如果异常没有catch block,那么xxxxx
  5. 结果放到变量a里
    1. 变量b是一个闭包, 那么要xxxx
    2. 变量b如果是一个局部变量, 那么要xxxx
    3. 变量b如果是一个全局变量, 那么要xxxx
    4. 变量b如果不存在, 那么要xxxx
    5. ….

可以看到,对于复杂的动态类型语言,行为的规定也变得异常复杂。具体到JS的规范中,JS语言的行为定义是多层次的,部分常见的”原子行为”被抽象成了规范中的一个条目: 

descript

比如上图的 ToInt16 行为,按照规范有1,2,3,4,5多个步骤,步骤中又会用到一些其他步骤,比如 ToNumber

自然而然的,我们可以想到,把这些规范中的行为变成一个个函数,规范中行为的步骤就变成了函数的一行行代码,然后函数和函数之间的调用就可以实现这么复杂的语言规范组织了。我们把这些逻辑叫做步骤函数

那么回到最初的问题,什么是解释执行,什么是JIT呢?从上面步骤函数 的概念来理解:

  • 解释执行: 把JS脚本转换成一个步骤函数数组存到内存里,然后执行时写一个大的while循环,把步骤函数的地址取出来,然后调用这个函数指针。
    1
    2
    3
    4
    5
    6
    7
    8
    Func array[] = {doVarGet, doVarGet, doAdd, doVarPut }

    void interpretor(VM* vm) {
    while(*array) { // 一步步执行所有步骤
    (*array)(vm); // 执行当前步骤
    array++; // 准备执行下一个步骤
    }
    }
  • JIT: 把一个个步骤函数的调用转换成机器码,不用软件的while来驱动,可以理解成这样的C代码
    1
    2
    3
    4
    5
    6
    void jit(VM* vm) {
    doVarGet(vm);
    doVarGet(vm);
    doAdd(vm);
    doVarPut(vm);
    }

从上面的概念上看来不管怎么样都应该是JIT的方式比解释执行的方式快,那么为什么还存在解释执行这种方式呢?有以下几个原因:

JIT生成的代码体积会比 array 数组(在真实情况下一般是字节码)大很多,内存消耗太大。
JIT需要运行时支持将内存页的权限标记为 "executable" 的,这在一些系统上是做不到的:比如iOS和鸿蒙出于安全原因禁止这种行为

对于 步骤函数 比较复杂的语言,while循环往下走一个循环的开销可能比起 doVarGet 来说是微不足道的,这样 JIT 比解释执行也快不了多少,甚至由于过大的可执行内存段,会经常造成 L1 Cache miss拖慢整体执行。这点下文会看到一个通过CPython实现Baseline JIT的例子。

另外,JIT(Just-In-Time) 是和 AOT (Ahead-Of-Time) 相对应的概念。这种从JS源码转换成机器码的过程是在运行时动态进行的,而不是像C语言一样预编译好的。对于很多只要执行一次的代码,进行JIT编译的开销加上执行JIT后代码的耗时,可能比直接用解释执行执行这些代码的要慢

解释执行为什么慢

从上一节我们已经理解了什么是JIT,但是我们经常听到一个”热门话题”:脚本语言的执行很慢。知其然知其所以然,我们这里要讲一下解释执行到底慢在哪里。

首先这里所说的解释执行一般是指动态类型的脚本语言,正如前一节所说的,动态类型的语言由于其类型的动态性,对于同一个”步骤函数”,在运行时要做大量的if else来处理不同类型的情况。另外由于解释执行对于Interpretor函数来说,while循环中下一次取出来的字节码是不是提前可知的,也就是这时候的对应的机器码中的 br 跳转指令,后面跟的地址是一个动态的地址。这些特点产生了如下问题:

  • if else 和 br 指令都会造成CPU流水线失效,不能有效的利用指令并行的能力。
  • 解释执行器中所有其他的逻辑都可以优化到和JIT差不多的水平,但唯独流水线失效的问题是根植于其原理上的,这点是解释执行慢的本质。

JIT到底能多快

当我们认识到解释执行为什么慢以后,自然而然走到了我们的第二个”热门话题”: “JIT真的超快的呢=w=”。知其然知其所以然,我们这里要讲一下JIT执行到底快在哪里。

首先不同于很多人先入为主的认识的是,Java 代码其实也是先解释执行的。Java 的 Class 文件其实就是Java的字节码,在JVM中,class文件会先以解释执行的方式进行执行。然后JVM在发现了一些热点函数之后,会对热点函数进行JIT编译来加速性能。众所周知JVM经常自己去和C++比较性能,然后说自己”甚至有时候比C++性能还好”。刨去其中王婆卖瓜的部分,其实JIT真的在一定条件下可以做到比肩甚至超过静态编译语言的性能的。但这是有一些前提的:

  1. JIT的语言本身要有静态的类型信息,不然翻译出来的代码又要有很多的if else类型判断,拖慢产物的执行性能。
  2. JIT的设计上要能进行和静态编译类似的各类分析优化,比如公共子表达式擦除,数据依赖分析,数据逃逸分析等等。
  3. VM的设计上要能很好的收集运行时的数据,来决定什么是热点,决定那些函数JIT化,哪些函数在JIT中inline等等。

所以其实做好一个高性能的JIT语言引擎,其难度不亚于实现一套llvm。如果没有体感的话我来举个JIT实际实现方式的例子: 

比如Java/JS这样的语言是支持NullPointerException的,遇到a.b中a是null/undefined 的情况,是可以在当场抛出Exception中断执行流程,然后外面可以通过catch这个Exception来防止整个程序崩溃的。 

但是对于C/汇编来说,访问一个空指针对象的字段时,是会直接触发sigfault信号造成程序崩溃的。那么我们怎么在JIT的代码中实现Exception呢?

对于解释执行来说,”步骤函数”: GetProperty中会增加一个if判断来专门处理这种情况。但是对于生成jit代码呢?难道也每个属性访问前面都if else?那不是和解释执行一样慢了么?

对于V8/JVM来说,他的实现方式是:

  1. JIT代码中不生成空指针检查的代码,让他触发sigfault
  2. 引擎监听linux系统的sigfault信号,然后根据信号触发时的地址偏移位置,反向推断出当前是哪一行代码出现了问题,出现了什么问题。
  3. 根据(2)中的计算结果,进行 de-optimize, 也就是重新回到解释执行模式中,让解释执行来触发Exception并处理。

那么怎么知道回到解释执行中的哪一个代码位置,回到这个位置有多少函数的状态是需要还原的,其中要有非常复杂的逻辑要处理。大概的类比就是实现一套 dwarf 功能 (C代码编译的调试符号文件)。

那么V8这种在JS这种动态语言上做JIT的引擎,其实比JavaJIT会更加困难,因为Java的输入起码是固定类型的,数字类型的 b+c 真的可以生成对应的机器码来执行。V8的想要做到类似的效果,需要在运行时动态地去收集运行时类型信息,比如一个函数中的 b+c 一直都是数字类型的,那么他可能就假设这个函数大概率是数字类型的,然后按照数字类型去生成一段JIT。后续在使用的时候要检查前置条件(b和c都是数字类型) 来决定是否可以使用这段JIT代码。

至此我们可以看到,JIT真的是可以做到非常快的执行的,在一些特定类型固定的函数上甚至可以生成和
C 代码编译结果一样质量的JIT函数的。但是其中 VM 要做到工作是非常非常复杂的。不妨看一下业界已有的一些带有JIT功能的语言引擎:

  1. Java/C#: Oracle和微软维护
  2. PHP: 社区版本没有JIT,Facebook自己做了一个带JIT的版本维护。
  3. V8/JSC: 谷歌和苹果维护

然后我们再看一下一些社区维护的语言:

  1. Python: 官方的CPython至今没有一个完善的JIT实现。PyPy可以实现JIT但是工业实践上很少使用。
  2. Lua: 官方不包含JIT。LuaJIT版本是一个大佬自己维护的,而且已经不再更新Lua最新版本的支持了。

V8的弯路

在V8刚推出的一段时间里,其实是走过一段时间设计上的弯路的,V8团队认为JIT编译应该是JS引擎的一级公民,所以默认把所有的JS代码都做了JIT baseline的编译,然后在挑选其中的热点函数做更高层次的优化编译。然后对比一下隔壁Safari的JavaScriptCore引擎,发现是”benchmark没输过,实际效果没赢过”。在实际打开网页的时候响应速度反而不及JIT做的没那么好的JSC引擎。这是由于JIT本身的启动开销是比较高的,对于网页的首屏正向影响不如编译带来的额外开销大

所以在现代的V8上,解释执行已经重新成为了执行的第一级方案。

WASM为什么快

WASM的前身是Firefox开发的 asm.js技术。asm.js 技术的思路很有意思,不妨在这里讲一下。

descript

可以看上面的这个例子,这是一个C代码通过asm.js编译器变成JS代码的例子,大家可以看到在这个JS产物里多了很多 |0 这样的奇怪代码,这其实是给JS引擎的JIT一个提示,代表了 (curr+1)|0 的结果一定是 数字类型。当JS引擎在一个确定的类型下进行JIT编译时,JIT产物的确定性和效率会好很多。这点也符合我们之前JIT的部分讲的,动态类型语言的JIT要在运行时收集类型信息,但是如果类型信息能在语法解析时就确定下来,那么对于JIT编译是一个极大的利好。

asm.js 产生以后,创造了一些在当年看起来仿佛神迹一般的效果,比如把 3D 游戏引擎在浏览器上高效的运行起来。顺着这个思路,几家浏览厂商说,既然要让JIT做得舒服,干嘛要JS进来插一脚,我们搞一个强类型的中间语言是不是比用JS更高效?

于是WASM诞生了。WASM提供了一个带有类型的IR,让C或其他静态编译的语言来生成。然后V8这种引擎可以利用IR进行高效快速的JIT编译,来获得接近C静态编译的运行时性能。

但是如果只是解释执行WASM代码,其效果就不会像JIT后一样对比JS有数量级的差异了。

WASM真的快么

这引出了我们的第三个热门话题: WASM真的快么?答案是确定也是否定的:

  1. 对于可以都做JIT的场景,WASM不一定能比JS快很多,必然JS在JIT做得够好的时候也可以很接近静态编译的效果。
  2. 但是WASM想要把JIT做好比JS要容易太多了。就拿de-optimize的事情来说,WASM里空指针是真的可以让WASM引擎进入不可恢复的状态的。这比起JS还要还原代码位置做退优化不知道简单了多少倍。
  3. 对于都不可做JIT的场景,WASM比JS快一点但不多。大家都是解释执行,不会有太大本质性差别。
  4. 对于WASM要嵌入到JS里,还要通过JS来访问各种外部API时,WASM和JS之间的通信成本甚至会导致严重的性能瓶颈。

那么 WASM 如果没有快很多,那么他真正的价值到底是什么?其实 WASM 最核心的不是一个面向性能的接近方案,他的最大贡献在于让以前很多C的代码可以不怎么改动就跑到浏览器上。这就极大扩展了浏览器的功能边界。

JIT实现例子

CPython的JIT实现例子

在2023年年末的时候,CPython的社区版本得到了一个JIT的提交,实现了一种基于copy-and-patch技术的JIT实现,这种JIT的实现很类似我们前面举例的这种例子:

1
2
3
4
5
6
void jit(VM* vm) {
doVarGet(vm);
doVarGet(vm);
doAdd(vm);
doVarPut(vm);
}

也就是说,他本质上还是把一段段的步骤函数组装起来,并没有在其中做更进一步的分析优化。所以在这个提交中作者自己也提到,现在的性能表现没有非常大的正向作用: 

descript

这里我不太同意他说这是后续一系列优化的基础。copy-and-patch这种方式丢失了比较多的结构信息,做各种分析类的优化时不太适用。但是一个开始总是好的。

LuaJIT的实现例子

LuaJIT的实现中包含了一个典型的动态语言引擎做JIT所需要的各类手段,可以从作者的这封邮件中看到

LuaJIT相比起CPython copy-and-patch的JIT实现明显正规的多。包含了IR,优化,退优化,寄存器分配等关键概念。想要自己实现JIT,LuaJIT应该是一个合适的参考对象。

解释执行真的慢么

有趣的是LuaJIT的作者写过另一个邮件,探讨了关于解释器为什么需要用汇编去编写: 

descript

如果我们去看V8 和 JSC的解释器,会发现他们利用了很复杂的汇编&代码生成机制来生成自己的解释器。其中的原因和上面邮件中提到的应该是很一致的: 

  • 现代编译器是为 “普通”的软件设计的,不能很好的处理 Interpretor这种特殊软件。具体表现在:
    1. 不能很好的分配寄存器。
    2. 不能很好的区分解释器中的fast-path 和 slow-path。造成流水线失效。

这篇JSC关于为什么要设计 “Low-Level Interpreter” 的文章中,也提到了类似的观点.

使用汇编代码维护解释器确实可以带来最优的执行效率,使得JSC这种JS引擎即使在没有JIT接入的情况下,解释器效率也是QuickJS的20%以上。但是引入汇编又会带来很大的维护性难题。后续的改造都会受到影响。那么有没有在保留C语言和编译器的前提下,实现一个也”差不多好”的解释器的方式呢?

我在研究CPython的copy-and-patch的文章过程中发现,这里有两篇文章描述了一种使用尾递归实现解析器来精确控制寄存器分配的方式。

总结来说很简单:

  1. 根据calling-convention,函数的参数总是会使用寄存器。这确保了寄存器分配的确定性。
  2. 编译器的尾递归优化会重用当前栈,使得尾递归调用的执行效果就是进行了一次没有sp操作的跳转。这模拟了解释器的while-switch循环行为。
  3. clang高版本的 [must-tail] 标记可以强制使用尾递归来优化,确保了编译产物的确定性。

利用这种手段,我们可以进一步提高解释器的效率。附加上诸如inline-cache,跳转地址内置,减少解释执行中的函数调用,增加各种fast-path的指令等方式,不失为一种优化解释执行器的好方案。

端上业务是否需要JIT

看你的业务类型,如果要在客户端上运行普通的页面,那么有没有JIT对性能影响并不大。这点从我们之前的benchmark上,以及业务的实际测试结果上都能确定。JS代码的执行速度对于首屏时间影响并没有那么大。

但是如果你的业务类型是执行一些复杂计算的,比如 three.js,比如一些矩阵计算代码,那么有 JIT 和没 JIT的差别就很大了。此时最好还是选用支持JIT的引擎。

扩展

扩展1:动态语言是否可以AOT

还记得我们最开始文章中说的V8的弯路么,在没有运行时信息收集的情况下对动态类型语言做全编译,效果并不好。编译的产物会非常大,而且效率上相比解释执行没有大的提升(类似于CPython的copy-and-patch JIT,可能就各位百分数的提升,反而需要付出巨大的内存代价)。

这也是为什么鸿蒙的ArkTS必须要在TS的基础上进一步做限制。因为TS的类型并不是强制的,在一个不强制类型的基础上做预编译效果会差非常多。

扩展2:多语言引擎真的可行么

另一个热门话题是能不能做一个VM来适配多种语言。不妨把已经存在的几种多语言引擎拿出来看看有什么特点。

  1. JVM: 在JVM上支持了Java, Kotlin, Groovy等多种语言。
  2. WASM: C Rust等静态编译语言都可以生成WASM。

既然上面这些多语言引擎都存在,那么是不是我们可以做一个引擎,”JJVM”,让他支持Java和JavaScript,那可真是太美好了,JavaScript真的变成 “Java”Script了。

你别说真有这种引擎 GraalVM。但是这里我们先按下不表来唱唱反调。

还记得上文提到的”步骤函数”概念么,JVM上的这些语言本质上共享了相同的”步骤函数”。他们在一些基础的原子操作上都是一样的,比如Java和Kotlin的int和Int的本质,都是JVM中对jint的使用。而WASM的”步骤函数”则是一批贴近于机器操作的指令,这也是由于静态语言门面向的目标本来就是机器码,而不管是arm64 还是x86,对于贴近硬件层面的数据处理方式都是类似的。

当”步骤函数”是相同的时候,共享一个VM的多语言就很好实现。但是如果你要实现的是Python和JS的共享引擎,他们的”步骤函数”行为上完全不一样。这时候怎么办?用更细粒度的”步骤函数”来实现各自的行为?这样性能会非常糟糕,要很多条字节码才能实现专有引擎中一条字节码做到的事情。把各自的”步骤函数”都实现一遍?那这样和把多个引擎重新实现一遍有什么区别。

那是不是这条路就走不通呢?我们来看看Graal是怎么做到”JJVM”的。

扩展3:GraalVM是怎么实现多语言引擎的

不熟悉GraalVM的同学可以看下这个介绍。这其实就是一个实验性的新的JVM,设计上具有更好的JIT能力来取代现有的JVM。但是GraalVM上有一个神奇的功能叫Truffle,他可以在JVM上实现各种动态语言比如Ruby,比如Python,比如JS。然后这些语言都可以JIT后以比肩V8这种原生引擎的效率来执行。同时又保持了和Java良好的可互操作性(毕竟运行在Java的VM上)。

那这种神奇的能力是怎么实现的, 我们刚才的介绍里不是说过 “步骤函数” 不一样不能共享实现么?

写代码中没有什么问题是加一个中间层解决不了的。

如果步骤函数不能一样,那我们在另一个层次上共享代码是不是就好了?

descript

可以看到这个代码是使用Truffle框架在GraalVM上实现一个自制语言的方式。是不是很像 AST树,然后在 AST树上增加了求值函数?没错GraalVM实现语言的方式就是,你不需要定义自己的字节码,你只需要用Java实现AST和AST的求值方式就可以。

这时候你从远古的记忆中想起,AST求值不是性能最烂的语言实现方式么?GraalVM用这种方式怎么能做到比肩V8的性能的?

说实在最初我也想不通这个,直到我看到了这个关于Truffle介绍的PPT。

descript

descript

descript

这个思路上有点难懂,但是真的搞明白了以后只感觉惊为天人。大概的意思是通过定向的JIT来不断对Truffle Node的方法做inline化(JIT/静态编译的一种常见优化手段),使得最终生成的机器代码中的逻辑被deduce到符合语言规范的最简代码。本质上来说是利用了宿主语言 JIT 来实现的动态语言生成器。

这是Oracle将近10年前发布的东西,领先程度令人惊讶。

另一个有趣的是前端时间在研究copy-and-patch的方案时候,
还发现有个大佬在研究通用语言引擎的生成器。本质上来说也是和Graal一样的方式,让用户通过编写基础块(“步骤函数”)的方式来搭建引擎,剩下的高效解释器,高效的JIT等功能,都由中间生成器生成。这和Graal的模式也很类似。

扩展4:汇编是否解决问题

看完了前面烧脑的东西,再回到我们的一些热门问题上,是不是上了汇编性能就会变好?

要看我们的汇编解决的是什么问题,现在的C编译器对于大部分的优化任务已经能做的比人还好了,要手写汇编的地方一般是要有特定的需求的,比如对 SIMD 的需求,比如对精确控制寄存器的需求。

那么这些需求到底存不存在,是要去看现有C编译产物的结果是否是最优解来确定的。如果看了编译器的结果,然后自己想不出比编译器更好的解决方案,那么用汇编就没有意义了。

抛开JIT不谈,动态语言解释器部分确实有一些可以应用汇编优化的地方。但是如上文我说的 tail-call模式的优化,其实已经可以解决绝大多数汇编需要解决的问题了,而且有更好的可维护性。那么不妨先把这部分做了再来观察编译器汇编的结果。再看有没有优化空间。

[转载] 深入理解内存分配

[转载] 深入理解内存分配

原文地址

相信大家在学习C语言的时候,malloc是最早遇到的几个方法之一,这里就来深入的了解下,macOS/iOS中用户空间的内存分配。

引言

首先,我们来看几个有意思的例子,以下几个在x86_64或者ARM64中的运行情况。

1
2
3
char *str = malloc(32);
free(str);
str[0] = 'a';
1
2
3
char *str = malloc(32);
free(str);
str[12] = 'a';
1
2
3
char *str = malloc(32);
free(str);
str[18] = 'a';

这里先说一下结果,之后再来分析为什么,看看你有没有猜对。

这里均不会在str[x] = 'a';这一行崩溃,而可能在下次内存分配的时候崩溃。

第一个会报malloc: *** error for object 0x60000003cfa0: Invalid pointer dequeued from free list

第二个会触发BAD_ACCESS的错误。

第三个运行一切正常,不会崩溃。

内核内存申请

malloc方法并不止提供了向内核申请内存(syscall)的功能,它还提供了一整套用户态的内存管理。比如linux-2.3之后使用的ptmalloc,FreeBSD使用的jemalloc,以及macOS/iOS使用的malloc_zonelibmalloc

向内核申请内存,触发系统调用,比较通用的接口有sbrkmmap。在mac上,sbrk已经被废弃,而所有内存申请的内核调用最终都会转到

1
2
3
4
5
6
7
kern_return_t mach_vm_allocate
(
vm_map_t target,
mach_vm_address_t *address,
mach_vm_size_t size,
int flags
);

这个内核方法,我们可以通过vm_allocate去间接的调用它。

有人建议使用系统自带的malloc来构建自己的内存管理程序,这样就不用考虑不同平台的差异性;也有人认为在别人的管理系统上创建,不能达到更好的性能。这些还是具体情况具体分析吧,后面会简单介绍下如何构建自己的内存管理系统。

回到内核内存,内核内存都是按页管理的,你不可能向内核申请1byte的内存,所有的内存申请都需要经过round,否则会导致申请内存失败,其定义如下:

1
2
3
4
5
externvm_size_tvm_page_size;

//These macros assume vm_page_size is a power-of-2.
#define trunc_page(x)((x) & (~(vm_page_size - 1)))
#define round_page(x)trunc_page((x) + (vm_page_size - 1))

用户态内存申请

用户态的内存管理方案实在太多了,这里主要说一下大家都比较通用的部分,以及libmalloc的实现。

由于系统提供的内存,最少是一页,那么程序如果申请小块内存,特别像Objc这种含有大量小内存的情况,我们总不可能为一个指针分配一页内存吧。

这里几乎所有的内存分配库都采用了相同的做法,即将内存分为不同大小来管理,某些地方称为size class,某些地方称为chunk,而mac中就是malloc_zone了。

mac中的malloc_zone大致分为以下几种:

  1. nano zone. <256
  2. tiny zone. <同nano
  3. small zone. <1024 bytes (64-bit), <512 bytes (32-bit)
  4. large zone.

申请不同大小的内存将会被派分到对应的zone,而各自的zone会采取不同的策略,比如nano, tiny, small是在内存页链表中寻找到一块拥有足够空闲空间的页,在这个页中分配该大小的内存;而large则是直接分配多个内存页,销毁的逻辑也完全不一样。

这里看到nano和tiny是重合的,他们之间有什么区别呢?这个问题放到下面多线程中去详细描述。

为什么需要将内存分配做这样的切分呢。由于我们平时使用到的内存大部分为小内存(这个在之后我会给一个统计结果),特别像是Objc这种语言,由于所有对象存在都是heap中的,所以基本都是以小指针对象,可能会导致大量小内存的申请和销毁,那么作为一个较为通用的内存分配器,那么肯定要考虑到优化小指针的分配效率。

这里再看一下Google的tcmalloc的划分策略。

The size-classes are spaced so that small sizes are separated by 8 bytes, larger sizes by 16 bytes, even larger sizes by 32 bytes, and so forth.

可以看到它对size-class的划分更为细致,而且它会在运行时根据具体情况具体可能会调整这个粒度,同时不会在同一页中分配任意size-class的内存,这样做是为了避免碎片。更高细粒度的划分会让程序在划分的时候更为简单,从而增加了效率,但这样也会增加缓冲内存的大小,个人觉得正是这个原因导致tcmalloc并没有考虑移动设备。

用户态内存销毁

以上说明了内存申请的方式,现在来看看如何销毁内存的。

如果是大块内存(large zone),那么视系统有没有指定内存页的缓存,否则就直接归还给系统。

那么如果是小内存(nano除外),在调用free之后

  1. 会先根据配置情况是否需要将内存重置为0x55,正常情况下不会执行这一步。
  2. 由于最小内存为2 * sizeof(void *),所以会将第一个指针位置更新成为一个token。
  3. 合并旁边的空闲内存。
  4. 将第二个指针位置更新为下一个空闲内存的地址或者NULL。
  5. 将当前空闲内存加入free-list缓存,当下次申请新内存的时候,会优先在缓存中寻找是否有适合的空闲内存段,没有才会向系统申请新的内存页。

这里和我们的理解上有些偏差了,free并没有第一时间把我们的内存还给系统,也就是说free之后的内存其实还是在用户空间的,我们有可能还是可以任意读写该段内存的。这也就是引言中的例子。

但是如果我们修改了小内存的第一个指针位置,会导致我们的token失效,结果在复用该free-list中的缓存时候,会去校验当前缓存的token,导致Invalid pointer dequeued from free list错误。就如下所示:

1
2
3
4
typedef struct chained_block_s {
uintptr_tdouble_free_guard;
struct chained_block_s*next;
} *chained_block_t;
1
2
3
4
5
void free(nanozone_t *nanozone, void *ptr) {
// ...
((chained_block_t)ptr)->double_free_guard = (0xBADDC0DEDEADBEADULL ^ nanozone->cookie);
// ...
}

而如果我们修改的是第二个指针位置的数据,则会导致该指针非NULL,导致查询下一个空闲内存块的时候内存访问错误。

而如果我们去修改其他位置的数据,则不会有任何问题。

这里我们看到,一些非常奇怪的崩溃,有可能是由于这种写入释放后指针引起的。

tcmalloc

可以看到上面的free过程中,是会有空闲内存的合并问题,这些当然也就会产生内存碎片。

1
2
3
|    64     | 64 |    |
| null | 64 | |
| 48 | null | 64 | v

如上图所示,中间的16byte可能就无法进行新的利用,好在我们的objc对象几乎都是几个指针的大小,加之malloc也会进行一次round,所以利用率还不错。

那么tcmalloc是怎么来进行优化的呢?由于tcmalloc在设计之初就不存在一个chunk中存在多个size-class的情况,所以一旦free,只需要将其丢进free-list中就可以了,在需要的时候再进行GC,将多余的空闲内存出让给别人或者还给系统。这样就避免了合并的性能开销。

多线程安全问题

现在的应用都是多线程的,按照我们上面所述的,均没有涉及到线程安全问题,那么最简单的方法就是对所有内存申请及销毁进行加锁。但是锁是一种相对比较耗资源的东西,普通锁可能会涉及到系统调用,spinlock又可能会导致优先级反转等问题,那么大家都是怎么解决这个问题的呢?

libmalloc的解决方式比较传统,也就是加锁,但是在nano malloc中会有特别的优化。

  1. 每个CPU都会分配一个属于自己的分配器,也就是说每个CPU都有属于自己的内存缓存。
  2. 内存划分和tcmalloc类似,一个slot(size-class)中只有一种大小的对象,这样就不存在内存合并的问题了。
  3. 在修改free-list的时候采用的是原子操作,而不是传统意义的锁。
  4. 只在需要扩展堆,也就是增长空闲内存的时候,才使用真正的锁。
  5. 64位系统才开始支持,因为需要指针长度达到64位。
  6. 所有的指针均有相同的开头,比如x86_64上一定是0x00006nnnnnnnnnnnarm上这个值会不一样。
  7. 所有的slot(size-class)最大容量均为0x20000大小,而里面存在的对象个数会不一样。
  8. 当申请对象个数超过对应slot的最大个数的时候(slot_exhausted),会fall through进入scalable zone进行申请。

造成以上几个魔法数字的原因是nona分配器使用指针储存了部分free-list的信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
struct nano_blk_addr_s {
uint64_t
nano_offset:NANO_OFFSET_BITS,// locates the block
nano_slot:NANO_SLOT_BITS,// bucket of homogenous quanta-multiple blocks
nano_band:NANO_BAND_BITS,
nano_mag_index:NANO_MAG_BITS,// the core that allocated this block
nano_signature:NANO_SIGNATURE_BITS;// the address range devoted to us.
};
// 这个是指针,也是该内存对象的信息
typedef union {
uint64_taddr;
struct nano_blk_addr_sfields;
} nano_blk_addr_t;

可以说nano_malloc_zone是专门为了OC而优化的。

tcmalloc和部分其他分配器(jemalloc),则是采取每一个线程上都独立拥有一个分配器,那么在该线程上进行free-list的操作时(申请内存的时候从缓存读取,及释放内存的时候直接加到缓存),就实现了无锁。当然,增长缓存以及GC等需要和其他线程交互的时候,还是需要锁的。这么做也会减少空闲内存的利用率。

思考

之前看到过如何解决一些主线程大量释放对象的问题,为了优化释放所消耗的时间,将所有释放工作都放到子线程中,这是否真的是一种好的方案呢?

内存分配优化

根据我们上面的分析,可以看到这些分配器都是通用型分配器,它考虑了各种长度大小的性能,但是没有考虑过一些对象的生命周期等。

在一些特殊的场景和应用中,比如音乐、视频、人工智能、游戏等,可能会出现大量特定长度的对象,也可能会出现一些常驻内存,而这些对象会导致通用内存分配器的性能降低,以及重复利用率降低。

如果我们要做到极致性能的内存管理,那么我们就需要进行分析应用的内存分配情况,以及性能。然后根据需要自定义内存管理模块,并与通用管理进行对比。

替换系统默认内存分配方式

替换默认malloc的方法很多,如果是使用的C++,替换new的方式也比较常见,鉴于默认new都是基于malloc实现的,这里只看替换malloc的方法。

define

1
#define malloc(size) my_malloc(size)

这种方法很傻瓜,只能替换可以被宏替换的地方,在部分场景替换还是很方便。

alias

1
2
void my_malloc () { /* Do something. */; }
void malloc () __attribute__ ((alias ("my_malloc")));

利用编译器进行符号的替换,这样可以替换本身以及静态库中的malloc。得益于MachO文件的二级命名空间,并不会替换动态库中的方法。

符号覆盖

1
2
3
void *malloc(size) { 
dlsym(RTLD_NEXT, "malloc");
}

在项目内可以直接定义新的malloc方法,链接器会将自身和静态库的malloc链接到自己的方法,如果需要调用原本的方法,可以使用dlsym(RTLD_NEXT, "malloc")。同样无法替换动态库的malloc。

mac上可行

1
__attribute__ ((section("__DATA, __interpose")))

iOS上被禁用的特性。

动态库符号链接替换

fish_hook提供了一种修改动态库符号链接的方法,前提是替换的被替换的对象需要在动态库中,也是只能替换映射到自身的malloc,无法替换动态库的方法。

但是这种方式比较灵活,可以根据情况动态的打开关闭。

malloc_zone

影响面最大的就是替换malloc_default_zone了,这样动态库的malloc也会使用新的内存管理。

系统并没有公开方法给我们替换default_zone的方法,其实私有方法也没有替换的方法,这里就用到了一个技巧,malloc_zone_unregister的时候,会将unregister_zone和zone列表最后一个zone交换来填补zone数组,所以就可以用以下方式来替换。

1
2
3
4
malloc_zone_register(my_zone);
malloc_zone_t *default_zone = malloc_default_zone()
malloc_zone_unregister(default_zone);
malloc_zone_register(default_zone);

替换完以后必须把unregister的注册回去,不然可能会导致某些对象释放时找不到对应的zone。

同时这些方法之间无法保证线程安全,由于内部的锁并未公开,所以这里需要在程序运行之前,也就是main函数开始时,或是更早进行替换。

这样我们就得到了一个完全属于自己的内存管理方案。

应用内内存使用分析

在进行替换之前,我们需要去分析当前内存使用状况,以及性能状态,从而才可以得知我们替换的内存管理方案有效。

为了做这个脚手架,也耗费了我相当长的时间。这里来看看如何去实现收集内存使用状况。这里就不能使用task_infohost_statisticssysctl这样粗略的统计方法了。

由于性能以及Objc对象无法完全摆脱malloc_zone(会导致统计的死循环),所以这里使用C++来实现统计分析。

线程安全

首先,需要考虑到的是线程安全,这里可以使用锁来简单的解决这个问题,但是这样同时也会大大影响性能,甚至可能会影响统计结果,所以这里采用ThreadLocal的方案。

每一个线程都有自己独立统计数据存放池,这样在新增数据等操作的时候就不需要加锁了,也尽量避免对性能有太大的影响。

malloc死循环

我们统计malloc,在生成统计数据的时候依然可能会调用到malloc,这样我们就可能形成了一个死循环,那么我们需要解决这种循环有两种方法。

  1. 在统计过程中修改标志位,统计结束重置该标志位,在这之间的malloc不进入统计。如果上面选择使用的是锁,那么这里也要加锁,如果上面选择的是ThreadLocal,那么这里每个线程也需要一个独立的标志。
  2. 修改内存申请方式,不用malloc,使用系统底层实现vm_allocate

这里,我选用第2中方案,为此需要C++的Allocator

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
template <class _Tp>
class VMAllocator : public std::allocator<_Tp> {
public:
typedef typename std::allocator<_Tp>::pointer pointer;
typedef typename std::allocator<_Tp>::size_type size_type;

pointer allocate(size_type __n, std::allocator<void>::const_pointer = 0)
{
size_type n = round_page(__n * sizeof(_Tp));
vm_address_t addr;
kern_return_t rt = vm_allocate(mach_task_self(), &addr, n, VM_FLAGS_ANYWHERE);
if (rt != KERN_SUCCESS) {
throw std::bad_alloc();
}
vm_protect(mach_task_self(), addr, n, true, VM_PROT_READ|VM_PROT_WRITE);
return reinterpret_cast<pointer>(addr);
}
void deallocate(pointer __p, size_type __n) noexcept
{
size_type n = round_page(__n);
kern_return_t rt = vm_deallocate(mach_task_self(), reinterpret_cast<vm_address_t>(__p), n);
if (rt != KERN_SUCCESS) {

}
}
};

获取每一个内存申请数据

那么我们如何去获取这样详细的统计数据呢?只能去hook malloc的方法了,这里我们需要去hook malloc_zone->malloc的方法。

我们如何才能获得malloc_zone的真正对象呢,其实这些对象都是有全局的名字的。

1
2
extern "C" malloc_zone_t **malloc_zones;
extern "C" int32_t malloc_num_zones;

其中malloc_zones[0]就是default_zone

由于malloc_zone是readonly状态,我们需要先修改权限才能继续hook。同时由上面所说的,这些都是非线程安全的操作,所以需要在启动的时候就完成,并且运行过程中不能修改。

1
2
3
mprotect(orig_zone_ptr_, sizeof(malloc_zone_t), PROT_READ|PROT_WRITE);
orig_zone_ptr_->malloc = Wrap::malloc;
mprotect(orig_zone_ptr_, sizeof(malloc_zone_t), PROT_READ);

logger

其实系统也开放了两个钩子对象,分别给我们统计系统调用和malloc调用的情况:

1
2
3
4
5
6
7
8
9
// For logging VM allocation and deallocation, arg1 here
// is the mach_port_name_t of the target task in which the
// alloc or dealloc is occurring. For example, for mmap()
// that would be mach_task_self(), but for a cross-task-capable
// call such as mach_vm_map(), it is the target task.

typedef void (malloc_logger_t)(uint32_t type, uintptr_t arg1, uintptr_t arg2, uintptr_t arg3, uintptr_t result, uint32_t num_hot_frames_to_skip);

extern malloc_logger_t *__syscall_logger;
1
2
3
4
5
6
7
8
9
// We set malloc_logger to NULL to disable logging, if we encounter errors
// during file writing
typedef void(malloc_logger_t)(uint32_t type,
uintptr_t arg1,
uintptr_t arg2,
uintptr_t arg3,
uintptr_t result,
uint32_t num_hot_frames_to_skip);
extern malloc_logger_t *malloc_logger;

由于这里我们不需要统计malloc的数据(我们更关心OC对象),但是我们还是希望了解系统调用发生的次数(系统调用是一种比较慢的操作)。

启动

这里我做了一个不完整的工具放在github,欢迎大家进行补充。只需要将动态库导入,并在程序开始的时候配置就可以了。

1
2
3
4
5
6
7
8
9
10
11
12
13
#import <MallocDetector/MallocDetector.h>

int main(int argc, char * argv[]) {
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
malloc_detector_show_inspector();
});
malloc_detector_attach_zone(true);
malloc_detector_start();

@autoreleasepool {
return UIApplicationMain(argc, argv, NSStringFromClass([Application class]), NSStringFromClass([AppDelegate class]));
}
}

统计结果

下面就来看看我在我们app里面统计得到的结果。

设备iPhone 7。在此期间系统调用12898次。

这里是内存申请大小之和按照时间顺序的情况,其中size作log2处理。

可以看到主线程都比较平稳,而ui线程则是和用户行为相关,网络更是和网络请求密切相关。

下面是内存size-class的分布,这里粒度比较低(2^n),个数(/1000)


可以看出来,我们对于256 bytes以下的对象占有绝对的比例,其中32 - 64 bytes最多。每个线程的分布也不一致,说明特定的业务场景会拥有不同的内存需求。

下面是不同大小耗费时间的分布,时间的单位为time_t

可以看出来256 bytes一下的时间消耗具有优势。

以上统计结果可能并不能代表所有,统计的样本也不够多,但也能代表部分真实状况。

替换default_zone

本来想替换为tcmalloc,但是它没有支持iOS系统,所以这里转而替换为jemalloc,由于时间有限,我也没有成功移植到arm上,所以这里看看模拟器的情况:

其中左边为苹果默认的分配器,右边为jemalloc


在内存分布相近的情况,jemalloc看似略微好于苹果默认分配器,但这种差距似乎很小,可能在误差之内。

最后

在移动应用中,内存的管理似乎并没有起到非常重要的地位,也不可能出现服务器那样的长时间运行,所以目前没有人做过这方面的优化处理。但是从这些点可以了解内存分配的一些情况,给我们一些不同的视角,具体情况下可以做一些特殊的优化。

[转载] QQ 客户端性能稳定性防劣化系统 Hodor 技术方案

[转载] QQ 客户端性能稳定性防劣化系统 Hodor 技术方案

转载: 原文地址

防劣化是比较经典的技术话题,手 Q 的防劣化系统从 2021 年 10 月开始投入研发,从 0 到 1 迭代了将近三年的时间,已经达到了业界先进水平。为了守护好手 Q 性能稳定性的门禁,我们将其命名为 Hodor 系统,即 Hold the door!

从验证可行性跑通最小闭环,到搭建群控机架一次次为集群扩容,实属不易。其中涉及到大量的方案讨论甚至推翻,很多思路和实现细节是业界找不到公开方案的,只能自己摸索。本文详细分享了手 Q 防劣化系统的构建链路,相信对业界和开发者们都有较高的借鉴意义。

为什么要做防劣化

  • 代码体量较大:业务涵盖 IM/空间/短视频/超级 QQ 秀等。
  • 迭代需求紧:双周迭代,研发人员多;每版本几十条需求分支,主干每周构建出包数百次。
  • 问题较多:需求合流新增性能问题多,基础侧人力寡不敌众,问题越堆越多,事后回溯效率低。

当业务的体量足够大,问题足够复杂的时候,解决问题的思路也需要转变。

descript

1.1 如何破局

盘点了下手 Q 研发流程的困局,现有的手段更着重于线上监控问题并在下个版本修复(甚至是下下个版本),如果能在开发阶段发布前甚至合入 master 之前就把问题扼杀在摇篮之中,就可以达到防劣化的目标。

descript

下图根据手 Q 真实惨痛案例改编,当年为了查一个严重的启动耗时劣化问题,二分法拉代码手动跑 Instruments 的痛,懂的都懂。

descript

此聊天记录为虚构,如有雷同纯属巧合

大家开发需求都爱赶 deadline,所以合流高峰期光靠堆人力代码 CR 和手动测试性能是不现实的,性能问题漏出事后优化也是不够的。因为业务的复杂性,总是优化赶不上劣化快。手 Q 在优化性能稳定性的同时,也提前布局防劣化系统,将其作为质量三位一体中的重要一环:

descript

1.2 我们的口号:Hold the door!

提前发现部分主路径问题,通过门禁防止性能劣化:

  1. 主干合流门禁:对于较稳定的性能指标,合流前自动检查。
  2. 日常自动提单:针对偶现的性能问题,开发阶段提前发现
  3. 性能数据看板:常态化详细数据看板,上帝视角观测性能。
  4. 告警机器人:自定义各性能维度告警规则,第一时间发现问题。

防劣化系统的实现

因为整套系统的实现比较复杂,考虑到整体篇幅限制,数据采集部分仅概述 iOS 平台的方案,各平台数据的上报协议及服务端的处理逻辑是共享的。

2.1 方案设计

要做好门禁,就需要把性能数据精确到每一次
commit,并做好科学的对比。现实情况是很复杂的,可能有各种各样的突发情况:

descript

基于以上诉求,我们开发了 feature 分支对比 master 的算法策略。建立全维度性能指标和科学归因方案。Hodor 实现了性能报告、数据分析、智能调度、提单告警、设备管理、用例管理等一系列能力,大概的运行机制如下:

descript

此方案的优点:

  1. 性能测试和性能报告创建审批左移到开发阶段。
  2. 覆盖场景可拓展:测试用例云端独立管理派发。
  3. 性能维度可拓展:支持 Instruments 所有模板。
  4. 静态检查可拓展:构建数据服务端存储与比对。

descript

2.1.1 防劣化 ≠ 自动化测试

现有测试平台和工具的不足:测试平台站在发现问题角度,缺乏解决问题思维;APM SDK 运行时采集能力受限

  1. 传统的自动化性能测试平台和工具的报告只有 metric 统计数据和 App 截图/日志,信息太少不足以定位问题。 
  2. APM 平台的线上监控很强大,但无法动态追踪采集 time profiler 等详细数据。

所以解决问题依然需要开发同学获取更详细信息或能够 debug 复现问题,而性能问题往往是偶现的。

descript

自动防劣化解决方案:打通发现和解决问题全链路:

  1. 通过 Instruments 动态追踪技术采集 diagnostic 诊断数据,无侵入性。
  2. xctrace 自动解析 trace 文件,翻译堆栈精准归因,还原『案发现场』。
  3. 每次提交构建均执行防劣化检测,精准定位问题的提交引入者。
  4. 数据可视化看板+自动提单派发+大模型 AI 分析问题 = 测开降本增效。

总之,手 Q 的解决方案从一开始就打破了传统思维,最终的方案也是真香!

descript

2.1.2 复杂业务下如何降低性能波动

俗话说细节决定成败,很多事情想跑通 demo 很容易,但是想达到高可用性,需要打磨很多很多细节才能应用到生产环境。比如为了控制变量降低场外因素波动,大到集群调度策略,小到机器零件型号,都锱铢必较。

descript

2.2 数据采集

在运行时性能数据采集方面,我们拥有一套自研方案;在静态扫描方面,也从编译链接过程采集了相关数据。

2.2.1 动态性能数据采集

性能采集方案选型之初,我们对于性能采集主要有以下几个诉求:

  1. 需要有详细的堆栈信息;
  2. 性能维度足够多;
  3. 最好非侵入式的;
  4. 容易与 CI 流程相结合。

为了满足上述诉求,最终我们选则了当时苹果刚推出的 xctrace 方案。

应用外数据采集:

xctrace

Instruments 是 iOS & macOS 平台进行性能分析必不可少的工具,它能采集到绝大多数的性能数据,同时拥有精美的 GUI 方便排查和分析。但是 Instruments一直都只有 GUI(古早年代曾经有过导出性能数据的功能,后面也去掉了),没有 CLI,这也使自动化使用 Instruments 进行性能采集和分析成为奢望。直到 Xcode 12,苹果终于推出了 Instruments 的 CLI 版本:xctrace。

descript

xctrace 提供了一系列命令,可以录制、导出性能数据,Instruments 支持哪些性能维度,xctrace
就支持哪些性能维度。我们根据不同的性能采集需求,生成不同的性能模板,使用 xctrace 录制对应的性能数据。录制好的 trace 文件可以通过 xctrace 导出为 XML 格式的文件,从 XML 文件中读取到性能数据。

值得一提的是,手 Q 作为早期第一批吃螃蟹使用 xctrace 的技术团队,我们一边摸着石头过河探索 workaround,一边与苹果团队合作以提升其可用性。Xcode 14 Release Notes 中解决的多个 issue 也是手 Q 团队在防劣化开发过程中向 Apple 提出的:

descript

所以手 Q 团队已经默默帮大家踩过很多坑了!

Xcode Memory Graph

在排查内存泄露相关问题时,使用 Instruments 内存相关模板只能看到对象的创建堆栈和引用计数的增减过程,无法展示对象间的引用关系。面对这类问题,Xcode Memory Graph 是更好的选择,但 Xcode Memory Graph 也是一个嵌入到 Xcode 的 GUI 程序,目前为止还没有 CLI 实现。为此,我们调研了 Xcode Memory Graph 的实现,获取到相关协议,实现脱离 GUI 生成 Xcode 内存图,并使用 heap、vmmap、leaks 等工具分析内存图,实现自动采集内存图,进行大内存占用和内存泄露的分析和监控。

Crash

Crash 的监控比较简单,我们是通过检查测试过程中设备上有没有新生成的 ips 文件方式来监测 Crash 的。这种方式的优点是监控范围广,SIGKILL、pre-main 阶段 Crash 等常规方式无法捕获的 Crash 也能监控到。实践中遇到的一个小坑是单台设备每天单个 App 生成 ips 文件是有上限的,之前测试阈值是 50,崩溃超过 50 次就不再生成 ips 文件。

高频日志

分析客户端日志是必不可少的排查问题的手段,但大型项目业务繁多,会出现很多无效高频日志,高频日志会占用大量的 IO 和 CPU 资源造成卡顿和发热,甚至有导致日志文件过大,无法打捞起来的情况。而且很多时候,高频日志的背后就是一段死循环或者循环调用逻辑的存在,所以我们对高频日志也进行了监控和告警提单。

应用内数据采集:

流量监控

流量下载的数据采集,虽然 Instruments Network 模块能够监控所有的下载请求,但 Network 上显示的流量大小依赖了 Response Header的 Content-Length 或 Range 字段。由于部分后台服务器并没有填写该字段,所以 Instruments 上无法获取总的下载流量大小,故而放弃 Instruments 上采集数据,改用 App 运行时收集数据。

业务打点

性能数据需要和业务场景进行关联,我们采用了苹果的 Signpost 方案进行打点,选择 Signpost 打点方案的原因主要是下面 3 点:

  1. 和 Instruments 高度契合,Instruments 有 os_signpost 模板,应用内使用 signpost 相关接口打的点,在 Instruments GUI 展示性能数据时,也能将业务打点一并展示,方便排查问题;
  2. signpost 打点数据可以使用 xctrace 进行导出,可以实现业务场景和性能数据的相关联;
  3. 相比 print 打点方式,signpost 性能损耗更低。

2.2.2 静态扫描能力

符号扫描

平台有两套符号扫描工具,都是面向链接期产物 (Mach-O Image)
进行静态扫描,分别洞察产物中的 Objective-C (简称 OC)
符号问题和原生符号问题。

OC 符号扫描:

OC 符号扫描工具,帮助扫描工程产物中存在的 OC Category 同名方法覆盖和 +load 静态初始化方法。

  • OC Category 同名方法覆盖是指 Category 机制隐含的运行时实现覆盖问题,覆盖问题有两种情形:1. 若主类 (原类) 存在一个与 Category 扩展方法同名的方法,则运行时会选择 Category 的实现使用。2. 若存在多个 Category 都对同一个类扩展了同名的方法,则运行时会选择其中一个 Category 的实现使用。这两种情况都可能导致程序逻辑非预期地调用到其他库的实现,出现功能异常或崩溃。该问题相对隐蔽不易被察觉,因为在链接期间不会产生警告。尽管代码规范要求 Category 方法名必须加前缀来规避该问题,但该问题在大型多源项目的集成过程中,还是时有发生,只是往往因为恰好兼容没出问题而没感知。直到某天改动后出现莫名异常,溯源后才发现。
  • +load 方法在程序加载的静态初始化阶段执行,会影响应用的启动耗时。

这两类方法(符号)对稳定性和性能有全局性的影响,因此平台建设了工具来关注这些符号。

工具综合基于 class-dump 和链接器生成的 LinkMap 信息 (如果有),获取产物中的全部 OC 符号和来源,统计筛选出重名 Category 方法和 +load 方法。并与 CI 构建检查相结合,监控和管控这两类问题方法,设立门禁要求业务新引入 +load 和重名方法须拉通基础侧 Review。

descript

descript

原生符号扫描:

原生符号扫描工具,帮助扫描工程所有依赖库中存在重复的库函数(符号) (主要关注 C 符号重复问题)。

通常重复的库函数是 C/C++ 编写的基础实用函数,这大部分归咎于 C/C++ 缺少广泛认可的依赖管理范式,部分大型业务静态库采取将其依赖的实用方法库也一同编译打包 (ar) 的范式而导致。这些实用方法库通常是广泛使用的基础实用库,如 FishHook、zip、libffi 等。若有多个业务静态库都集成了同源的基础实用库,在链接 (ld) 生成可执行程序时,链接器会选择其中一份链接 (取决于链接先后顺序等因素,可以通过 LinkMap 确认选用的实现),它们虽然具有相同的符号 (API),但版本/实现未必一致、ABI 未必兼容,所以如果链接时选取的实现不恰当,则可能出现功能异常或崩溃。

通过原生符号扫描工具,扫描出重复的库函数,有助于标识出上述这样”存在多份重复选其一不兼容”的潜在风险。

工具的工作流程是解析链接 (ld) 参数,遍历每一个参与链接的静态库,使用 nm 工具等工具读取它们包含的对外导出 (External & Defined) 符号。实践中集成到 CI,在构建完成后的现场回溯构建日志取得链接 (ld) 参数并执行,统计出重复的原生符号并根据规则登记归档。

descript

最终的统计结果会展示在 Hodor 平台,可以查看每个 commit 的重复符号变化情况:

descript

dyld hash 碰撞扫描

在 Hodor 防劣化系统上线了一段时候后,我们抓到了一个冷启动劣化的 case:主干上一个新提交导致冷启动 T0 阶段(pre-main)劣化了 150+ ms,但该提交只修改了一个方法名。在排除掉外部因素、测试环境稳定性因素之后,发现真的因为一个方法名导致冷启动劣化那么多。问题看起来很棘手,幸好,我们有详细的 trace 文件,在详细分析对比了劣化前后的堆栈之后,发现劣化的根源是冷启动创建启动闭包时调用了一个 perfect hash 的算法,新修改的方法名导致这个算法的碰撞次数增加了,发生碰撞的情况下,需要 rehash,于是耗时增加。以下是生成启动闭包的简要流程:

descript

找到了劣化的原因,那如何找到发生碰撞的方法名呢?答案只能去 dyld 源码里找,还好,dyld 是开源的,我们在 dyld 源码里找到了生成启动闭包相关的部分,在发生碰撞的地方输出对应的 sel 名字,将其编译起来后,把 QQ 的 Mach-O 扫一遍,这样我们就找到了所有的碰撞点,之后我们修复了所有的碰撞点,冷启动得以降低了 700 ms(iPhone 11)。

我们将该扫描工具部署回了 Hodor 系统,监测每一个提交的碰撞情况,同时我们也将这个问题反馈给了苹果负责 linker 的团队。

2.3 任务调度

职责在于监听 Git 事件与自定义事件并生成多类型的性能测试任务,在合适的时间点将任务与合适的测试机进行匹配然后生成配置文件,最后将配置文件派发到数据采集端驱动性能测试任务在对应测试机上执行。

2.3.1 任务类型

防劣化性能测试任务主要分为以下几大类

descript

主流程测试:

由基础侧提供的核心测试用例组,测试流程包括手Q的几个核心场景进行测试(启动、登录、AIO、频道、短视频等),所有分支默认运行当前测试用例组。

后续性能报告也是基于当前用例组所上报的性能数据来进行对比。保证统一的测试用例流程与环境,性能数据的对比才是可信任的。

专项测试:

针对某些性能维度(内存、IO、预下载流量检测等)单独进行测试。最终生成相应性能看板。

自定义用例测试:

手 Q 功能场景十分的庞大复杂,基础用例也无法覆盖到所有的场景,由此诞生自定义测试用例功能。

如果业务同学想观察自己所处业务部分详细的性能数据,防劣化系统支持由各业务来编写自定义的测试用例,测试完毕后根据上报数据与定义的场景将自动生成相应性能看板。

Crash、Monkey 测试:

在日常开发中,发生 Crash 问题将会严重影响整个项目开发进度。我们希望能第一时间将问题检测暴露出来并推动修改。

启动以及主流程 Crash 则是最为严重的,直接导致项目不可使用,影响大家日常开发。Master 主干的每一个 Commit 合入都会进行 Crash 测试。如果发生 Crash,会立即拉群通知排查。而对于非启动以及主流程 Crash 问题则会进行自动提单。

而 Monkey 测试则是模拟用户操作,无序进行操作。能够尽可能的将 Crash 问题暴露出来。

闲时利用:

为了更充分的利用防劣化系统,在空闲时间(深夜、周末)会对过去已经测试过的主干 Commit 再次进行测试。用尽可能多的测试来暴露出更多的问题。

2.3.2 任务调度管理

所有生成的测试任务会根据任务类型,优先级等条件进行一轮排序,最终优先保证最紧急的任务最优先执行。

简单示意图如下:

descript

当任务状态异常时,也会有告警。

descript

2.3.3 设备管理

针对不同类型的任务采用不同的策略进行测试机分配。

  1. 对于 Crash 任务,为了保证能第一时间发现问题,会分配专门的机器池进行测试。
  2. 对于性能任务,根据版本流程与任务优先级进行动态分配。基础性能>业务自定义>=专项测试>闲时利用。

设备环境发生问题,也将及时进行告警:

descript

2.4 数据处理

由于 Instruments 采集到的性能数据量巨大,动辄 GB 级别,无法全量上报,所以性能数据采集时会进行符号化和性能问题的分析,比如找出卡顿堆栈、内存泄露的对象等。分析完毕后会将数据上报给服务端,由服务端进一步处理。

职责在于将上报的数据根据不同规则进行计算存储。不同维度性能根据不同规则计算,得出相应的性能结果并消费劣化性能数据(自动提单与告警)。

2.4.1 不同类型性能数据的处理

  1. 基础性能数据 对于基础性能数据而言(CPU、内存、IO、线程数),上报的数据是原始每一次采样所得数据(大致在一秒采样一次),这里诞生了两种计算方式。
    1. 对于关注整体性能数据以及流程比较短的用例,则会整体计算出三个维度的数据:峰值数据、平均数据、结束时数据。
    2. 对于有定义「场景」的用例,会根据所传递的打点(Signpost)值来找到对应时间范围的数据进行计算。同样是以上三个基础维度,另外新增一个耗时计算。 整体示意图如下:
      descript
  2. 重点性能数据 对于重点关注的数据(启动时间,启动线程状态),采集端会使用专门的模板来进行测试,上报数据后。Server 端对多次测试结果数据进行综合计算,得出结果最后展示在相应看板上。
    descript
  3. 自定义性能数据 对于自定义上报数据(重复符号变动,启动阶段函数监控),则是开放专门上报数据接口,由对应业务方自主计算上传(防劣化会向业务方提供基本数据)。防劣化系统负责记录数据并展示相应看板。
    descript

2.4.2 消费性能劣化数据

1、  自动提单

我们会定时扫描数据库中上报的性能劣化信息。先根据白名单以及过滤规则进行筛选,然后将需要提单的数据进行信息聚合,最终以提单的形式将问题自动分配给对应的业务负责人。

descript

bug 单包含了缺陷的堆栈等详细信息:

descript

2、  性能告警

对于主干的性能数据进行实时监控,不同用例不同性能维度可以配置不同的告警规则。当发生劣化时及时将对应的信息抛到对应业务群中进行告警。

descript

2.5 管理端展示

2.5.1 防劣化看板

防劣化看板支持查看指定时间、分支、测试用例和场景下的每个 commit 的状态以及各项性能数据,并可以快速标记 commit,支持与任意 commit 的性能数据做对比。

descript

2.5.2 分支性能报告

防劣化系统会对所有需求分支的每一次 push 进行性能测试,然后与对应的主干 Commit 性能数据进行对比生成性能报告。能直观的看到需求分支的性能变化,当性能发生劣化的时候也能直观的看到是从哪个 Commit 开始引入劣化问题,方便问题排查。

descript

测试报告有多种状态,比如”等待数据上报”、”自动审批通过”、”自动审批不通过” 等:

descript

descript

descript

当测试报告”自动审批不通过” 时,也会标注出是哪些指标不通过,便于开发者迅速定位问题:

descript

2.5.3 测试用例管理

基础所提供的主流程测试用例必然是无法覆盖手 Q 所有的场景,因此提供开放能力,支持各业务方的开发、测试自主提供测试用例。在防劣化平台上进行配置测试,测试完毕后自动根据配置生成相应的性能看板。

descript

descript

同时对正在运行的测试用例进行成功率监控,低于一定的成功率将进行告警。如业务方在一段时间内没有处理告警,会将其临时下架避免资源浪费。

descript

2.6 整体架构

descript

收益与总结

Hodor 上线后收益显著,研发效率大幅提升!

descript

通过将问题发现和解决左移到开发阶段,可以有效防止问题漏出到线上导致大盘数据劣化。如某次提交导致主干启动耗时上涨,基于防劣化系统可精准快速定位到代码提交者。

descript

Hodor 系统还在不断迭代中,2024 年还拓展了 QQ 桌面客户端,并在运行效率方面持续优化。目前防劣化系统已经落地了 QQ 各平台。

防劣化系统从 0 到 1 迭代了将近三年的时间。从验证可行性跑通最小闭环,到搭建群控机架一次次为集群扩容,实属不易。其中涉及到大量的方案讨论甚至推翻,很多思路和实现细节是业界找不到公开方案的,只能自己摸索。

在建设过程中我们遇到了不少很底层的问题需要与厂商沟通,比如与 Apple 的技术专家们线上和线下交流过程中也学到了不少,在此也感谢 Apple。

客户端的性能稳定性防劣化是一个很复杂的话题,而且只有体量足够大的业务才会面临更多的挑战。正因为我们面对的很多问题业界都无先例可循,所以也期待行业内后续有更多的分享和交流。同时,我们也期望 Hodor 不仅在性能稳定性方面发挥作用,未来也会把手 Q 研发效能的各项指标集成进来。

-End-

[转载] QQ 客户端性能稳定性防劣化系统 Hodor 技术方案

[转载] QQ 客户端性能稳定性防劣化系统 Hodor 技术方案

转载: 原文地址

防劣化是比较经典的技术话题,手 Q 的防劣化系统从 2021 年 10 月开始投入研发,从 0 到 1 迭代了将近三年的时间,已经达到了业界先进水平。为了守护好手 Q 性能稳定性的门禁,我们将其命名为 Hodor 系统,即 Hold the door!

从验证可行性跑通最小闭环,到搭建群控机架一次次为集群扩容,实属不易。其中涉及到大量的方案讨论甚至推翻,很多思路和实现细节是业界找不到公开方案的,只能自己摸索。本文详细分享了手 Q 防劣化系统的构建链路,相信对业界和开发者们都有较高的借鉴意义。

为什么要做防劣化

  • 代码体量较大:业务涵盖 IM/空间/短视频/超级 QQ 秀等。
  • 迭代需求紧:双周迭代,研发人员多;每版本几十条需求分支,主干每周构建出包数百次。
  • 问题较多:需求合流新增性能问题多,基础侧人力寡不敌众,问题越堆越多,事后回溯效率低。

当业务的体量足够大,问题足够复杂的时候,解决问题的思路也需要转变。

descript

1.1 如何破局

盘点了下手 Q 研发流程的困局,现有的手段更着重于线上监控问题并在下个版本修复(甚至是下下个版本),如果能在开发阶段发布前甚至合入 master 之前就把问题扼杀在摇篮之中,就可以达到防劣化的目标。

descript

下图根据手 Q 真实惨痛案例改编,当年为了查一个严重的启动耗时劣化问题,二分法拉代码手动跑 Instruments 的痛,懂的都懂。

descript

此聊天记录为虚构,如有雷同纯属巧合

大家开发需求都爱赶 deadline,所以合流高峰期光靠堆人力代码 CR 和手动测试性能是不现实的,性能问题漏出事后优化也是不够的。因为业务的复杂性,总是优化赶不上劣化快。手 Q 在优化性能稳定性的同时,也提前布局防劣化系统,将其作为质量三位一体中的重要一环:

descript

1.2 我们的口号:Hold the door!

提前发现部分主路径问题,通过门禁防止性能劣化:

  1. 主干合流门禁:对于较稳定的性能指标,合流前自动检查。
  2. 日常自动提单:针对偶现的性能问题,开发阶段提前发现
  3. 性能数据看板:常态化详细数据看板,上帝视角观测性能。
  4. 告警机器人:自定义各性能维度告警规则,第一时间发现问题。

防劣化系统的实现

因为整套系统的实现比较复杂,考虑到整体篇幅限制,数据采集部分仅概述 iOS 平台的方案,各平台数据的上报协议及服务端的处理逻辑是共享的。

2.1 方案设计

要做好门禁,就需要把性能数据精确到每一次
commit,并做好科学的对比。现实情况是很复杂的,可能有各种各样的突发情况:

descript

基于以上诉求,我们开发了 feature 分支对比 master 的算法策略。建立全维度性能指标和科学归因方案。Hodor 实现了性能报告、数据分析、智能调度、提单告警、设备管理、用例管理等一系列能力,大概的运行机制如下:

descript

此方案的优点:

  1. 性能测试和性能报告创建审批左移到开发阶段。
  2. 覆盖场景可拓展:测试用例云端独立管理派发。
  3. 性能维度可拓展:支持 Instruments 所有模板。
  4. 静态检查可拓展:构建数据服务端存储与比对。

descript

2.1.1 防劣化 ≠ 自动化测试

现有测试平台和工具的不足:测试平台站在发现问题角度,缺乏解决问题思维;APM SDK 运行时采集能力受限

  1. 传统的自动化性能测试平台和工具的报告只有 metric 统计数据和 App 截图/日志,信息太少不足以定位问题。 
  2. APM 平台的线上监控很强大,但无法动态追踪采集 time profiler 等详细数据。

所以解决问题依然需要开发同学获取更详细信息或能够 debug 复现问题,而性能问题往往是偶现的。

descript

自动防劣化解决方案:打通发现和解决问题全链路:

  1. 通过 Instruments 动态追踪技术采集 diagnostic 诊断数据,无侵入性。
  2. xctrace 自动解析 trace 文件,翻译堆栈精准归因,还原『案发现场』。
  3. 每次提交构建均执行防劣化检测,精准定位问题的提交引入者。
  4. 数据可视化看板+自动提单派发+大模型 AI 分析问题 = 测开降本增效。

总之,手 Q 的解决方案从一开始就打破了传统思维,最终的方案也是真香!

descript

2.1.2 复杂业务下如何降低性能波动

俗话说细节决定成败,很多事情想跑通 demo 很容易,但是想达到高可用性,需要打磨很多很多细节才能应用到生产环境。比如为了控制变量降低场外因素波动,大到集群调度策略,小到机器零件型号,都锱铢必较。

descript

2.2 数据采集

在运行时性能数据采集方面,我们拥有一套自研方案;在静态扫描方面,也从编译链接过程采集了相关数据。

2.2.1 动态性能数据采集

性能采集方案选型之初,我们对于性能采集主要有以下几个诉求:

  1. 需要有详细的堆栈信息;
  2. 性能维度足够多;
  3. 最好非侵入式的;
  4. 容易与 CI 流程相结合。

为了满足上述诉求,最终我们选则了当时苹果刚推出的 xctrace 方案。

应用外数据采集:

xctrace

Instruments 是 iOS & macOS 平台进行性能分析必不可少的工具,它能采集到绝大多数的性能数据,同时拥有精美的 GUI 方便排查和分析。但是 Instruments一直都只有 GUI(古早年代曾经有过导出性能数据的功能,后面也去掉了),没有 CLI,这也使自动化使用 Instruments 进行性能采集和分析成为奢望。直到 Xcode 12,苹果终于推出了 Instruments 的 CLI 版本:xctrace。

descript

xctrace 提供了一系列命令,可以录制、导出性能数据,Instruments 支持哪些性能维度,xctrace
就支持哪些性能维度。我们根据不同的性能采集需求,生成不同的性能模板,使用 xctrace 录制对应的性能数据。录制好的 trace 文件可以通过 xctrace 导出为 XML 格式的文件,从 XML 文件中读取到性能数据。

值得一提的是,手 Q 作为早期第一批吃螃蟹使用 xctrace 的技术团队,我们一边摸着石头过河探索 workaround,一边与苹果团队合作以提升其可用性。Xcode 14 Release Notes 中解决的多个 issue 也是手 Q 团队在防劣化开发过程中向 Apple 提出的:

descript

所以手 Q 团队已经默默帮大家踩过很多坑了!

Xcode Memory Graph

在排查内存泄露相关问题时,使用 Instruments 内存相关模板只能看到对象的创建堆栈和引用计数的增减过程,无法展示对象间的引用关系。面对这类问题,Xcode Memory Graph 是更好的选择,但 Xcode Memory Graph 也是一个嵌入到 Xcode 的 GUI 程序,目前为止还没有 CLI 实现。为此,我们调研了 Xcode Memory Graph 的实现,获取到相关协议,实现脱离 GUI 生成 Xcode 内存图,并使用 heap、vmmap、leaks 等工具分析内存图,实现自动采集内存图,进行大内存占用和内存泄露的分析和监控。

Crash

Crash 的监控比较简单,我们是通过检查测试过程中设备上有没有新生成的 ips 文件方式来监测 Crash 的。这种方式的优点是监控范围广,SIGKILL、pre-main 阶段 Crash 等常规方式无法捕获的 Crash 也能监控到。实践中遇到的一个小坑是单台设备每天单个 App 生成 ips 文件是有上限的,之前测试阈值是 50,崩溃超过 50 次就不再生成 ips 文件。

高频日志

分析客户端日志是必不可少的排查问题的手段,但大型项目业务繁多,会出现很多无效高频日志,高频日志会占用大量的 IO 和 CPU 资源造成卡顿和发热,甚至有导致日志文件过大,无法打捞起来的情况。而且很多时候,高频日志的背后就是一段死循环或者循环调用逻辑的存在,所以我们对高频日志也进行了监控和告警提单。

应用内数据采集:

流量监控

流量下载的数据采集,虽然 Instruments Network 模块能够监控所有的下载请求,但 Network 上显示的流量大小依赖了 Response Header的 Content-Length 或 Range 字段。由于部分后台服务器并没有填写该字段,所以 Instruments 上无法获取总的下载流量大小,故而放弃 Instruments 上采集数据,改用 App 运行时收集数据。

业务打点

性能数据需要和业务场景进行关联,我们采用了苹果的 Signpost 方案进行打点,选择 Signpost 打点方案的原因主要是下面 3 点:

  1. 和 Instruments 高度契合,Instruments 有 os_signpost 模板,应用内使用 signpost 相关接口打的点,在 Instruments GUI 展示性能数据时,也能将业务打点一并展示,方便排查问题;
  2. signpost 打点数据可以使用 xctrace 进行导出,可以实现业务场景和性能数据的相关联;
  3. 相比 print 打点方式,signpost 性能损耗更低。

2.2.2 静态扫描能力

符号扫描

平台有两套符号扫描工具,都是面向链接期产物 (Mach-O Image)
进行静态扫描,分别洞察产物中的 Objective-C (简称 OC)
符号问题和原生符号问题。

OC 符号扫描:

OC 符号扫描工具,帮助扫描工程产物中存在的 OC Category 同名方法覆盖和 +load 静态初始化方法。

  • OC Category 同名方法覆盖是指 Category 机制隐含的运行时实现覆盖问题,覆盖问题有两种情形:1. 若主类 (原类) 存在一个与 Category 扩展方法同名的方法,则运行时会选择 Category 的实现使用。2. 若存在多个 Category 都对同一个类扩展了同名的方法,则运行时会选择其中一个 Category 的实现使用。这两种情况都可能导致程序逻辑非预期地调用到其他库的实现,出现功能异常或崩溃。该问题相对隐蔽不易被察觉,因为在链接期间不会产生警告。尽管代码规范要求 Category 方法名必须加前缀来规避该问题,但该问题在大型多源项目的集成过程中,还是时有发生,只是往往因为恰好兼容没出问题而没感知。直到某天改动后出现莫名异常,溯源后才发现。
  • +load 方法在程序加载的静态初始化阶段执行,会影响应用的启动耗时。

这两类方法(符号)对稳定性和性能有全局性的影响,因此平台建设了工具来关注这些符号。

工具综合基于 class-dump 和链接器生成的 LinkMap 信息 (如果有),获取产物中的全部 OC 符号和来源,统计筛选出重名 Category 方法和 +load 方法。并与 CI 构建检查相结合,监控和管控这两类问题方法,设立门禁要求业务新引入 +load 和重名方法须拉通基础侧 Review。

descript

descript

原生符号扫描:

原生符号扫描工具,帮助扫描工程所有依赖库中存在重复的库函数(符号) (主要关注 C 符号重复问题)。

通常重复的库函数是 C/C++ 编写的基础实用函数,这大部分归咎于 C/C++ 缺少广泛认可的依赖管理范式,部分大型业务静态库采取将其依赖的实用方法库也一同编译打包 (ar) 的范式而导致。这些实用方法库通常是广泛使用的基础实用库,如 FishHook、zip、libffi 等。若有多个业务静态库都集成了同源的基础实用库,在链接 (ld) 生成可执行程序时,链接器会选择其中一份链接 (取决于链接先后顺序等因素,可以通过 LinkMap 确认选用的实现),它们虽然具有相同的符号 (API),但版本/实现未必一致、ABI 未必兼容,所以如果链接时选取的实现不恰当,则可能出现功能异常或崩溃。

通过原生符号扫描工具,扫描出重复的库函数,有助于标识出上述这样”存在多份重复选其一不兼容”的潜在风险。

工具的工作流程是解析链接 (ld) 参数,遍历每一个参与链接的静态库,使用 nm 工具等工具读取它们包含的对外导出 (External & Defined) 符号。实践中集成到 CI,在构建完成后的现场回溯构建日志取得链接 (ld) 参数并执行,统计出重复的原生符号并根据规则登记归档。

descript

最终的统计结果会展示在 Hodor 平台,可以查看每个 commit 的重复符号变化情况:

descript

dyld hash 碰撞扫描

在 Hodor 防劣化系统上线了一段时候后,我们抓到了一个冷启动劣化的 case:主干上一个新提交导致冷启动 T0 阶段(pre-main)劣化了 150+ ms,但该提交只修改了一个方法名。在排除掉外部因素、测试环境稳定性因素之后,发现真的因为一个方法名导致冷启动劣化那么多。问题看起来很棘手,幸好,我们有详细的 trace 文件,在详细分析对比了劣化前后的堆栈之后,发现劣化的根源是冷启动创建启动闭包时调用了一个 perfect hash 的算法,新修改的方法名导致这个算法的碰撞次数增加了,发生碰撞的情况下,需要 rehash,于是耗时增加。以下是生成启动闭包的简要流程:

descript

找到了劣化的原因,那如何找到发生碰撞的方法名呢?答案只能去 dyld 源码里找,还好,dyld 是开源的,我们在 dyld 源码里找到了生成启动闭包相关的部分,在发生碰撞的地方输出对应的 sel 名字,将其编译起来后,把 QQ 的 Mach-O 扫一遍,这样我们就找到了所有的碰撞点,之后我们修复了所有的碰撞点,冷启动得以降低了 700 ms(iPhone 11)。

我们将该扫描工具部署回了 Hodor 系统,监测每一个提交的碰撞情况,同时我们也将这个问题反馈给了苹果负责 linker 的团队。

2.3 任务调度

职责在于监听 Git 事件与自定义事件并生成多类型的性能测试任务,在合适的时间点将任务与合适的测试机进行匹配然后生成配置文件,最后将配置文件派发到数据采集端驱动性能测试任务在对应测试机上执行。

2.3.1 任务类型

防劣化性能测试任务主要分为以下几大类

descript

主流程测试:

由基础侧提供的核心测试用例组,测试流程包括手Q的几个核心场景进行测试(启动、登录、AIO、频道、短视频等),所有分支默认运行当前测试用例组。

后续性能报告也是基于当前用例组所上报的性能数据来进行对比。保证统一的测试用例流程与环境,性能数据的对比才是可信任的。

专项测试:

针对某些性能维度(内存、IO、预下载流量检测等)单独进行测试。最终生成相应性能看板。

自定义用例测试:

手 Q 功能场景十分的庞大复杂,基础用例也无法覆盖到所有的场景,由此诞生自定义测试用例功能。

如果业务同学想观察自己所处业务部分详细的性能数据,防劣化系统支持由各业务来编写自定义的测试用例,测试完毕后根据上报数据与定义的场景将自动生成相应性能看板。

Crash、Monkey 测试:

在日常开发中,发生 Crash 问题将会严重影响整个项目开发进度。我们希望能第一时间将问题检测暴露出来并推动修改。

启动以及主流程 Crash 则是最为严重的,直接导致项目不可使用,影响大家日常开发。Master 主干的每一个 Commit 合入都会进行 Crash 测试。如果发生 Crash,会立即拉群通知排查。而对于非启动以及主流程 Crash 问题则会进行自动提单。

而 Monkey 测试则是模拟用户操作,无序进行操作。能够尽可能的将 Crash 问题暴露出来。

闲时利用:

为了更充分的利用防劣化系统,在空闲时间(深夜、周末)会对过去已经测试过的主干 Commit 再次进行测试。用尽可能多的测试来暴露出更多的问题。

2.3.2 任务调度管理

所有生成的测试任务会根据任务类型,优先级等条件进行一轮排序,最终优先保证最紧急的任务最优先执行。

简单示意图如下:

descript

当任务状态异常时,也会有告警。

descript

2.3.3 设备管理

针对不同类型的任务采用不同的策略进行测试机分配。

  1. 对于 Crash 任务,为了保证能第一时间发现问题,会分配专门的机器池进行测试。
  2. 对于性能任务,根据版本流程与任务优先级进行动态分配。基础性能>业务自定义>=专项测试>闲时利用。

设备环境发生问题,也将及时进行告警:

descript

2.4 数据处理

由于 Instruments 采集到的性能数据量巨大,动辄 GB 级别,无法全量上报,所以性能数据采集时会进行符号化和性能问题的分析,比如找出卡顿堆栈、内存泄露的对象等。分析完毕后会将数据上报给服务端,由服务端进一步处理。

职责在于将上报的数据根据不同规则进行计算存储。不同维度性能根据不同规则计算,得出相应的性能结果并消费劣化性能数据(自动提单与告警)。

2.4.1 不同类型性能数据的处理

  1. 基础性能数据 对于基础性能数据而言(CPU、内存、IO、线程数),上报的数据是原始每一次采样所得数据(大致在一秒采样一次),这里诞生了两种计算方式。
    1. 对于关注整体性能数据以及流程比较短的用例,则会整体计算出三个维度的数据:峰值数据、平均数据、结束时数据。
    2. 对于有定义「场景」的用例,会根据所传递的打点(Signpost)值来找到对应时间范围的数据进行计算。同样是以上三个基础维度,另外新增一个耗时计算。 整体示意图如下:
      descript
  2. 重点性能数据 对于重点关注的数据(启动时间,启动线程状态),采集端会使用专门的模板来进行测试,上报数据后。Server 端对多次测试结果数据进行综合计算,得出结果最后展示在相应看板上。
    descript
  3. 自定义性能数据 对于自定义上报数据(重复符号变动,启动阶段函数监控),则是开放专门上报数据接口,由对应业务方自主计算上传(防劣化会向业务方提供基本数据)。防劣化系统负责记录数据并展示相应看板。
    descript

2.4.2 消费性能劣化数据

1、  自动提单

我们会定时扫描数据库中上报的性能劣化信息。先根据白名单以及过滤规则进行筛选,然后将需要提单的数据进行信息聚合,最终以提单的形式将问题自动分配给对应的业务负责人。

descript

bug 单包含了缺陷的堆栈等详细信息:

descript

2、  性能告警

对于主干的性能数据进行实时监控,不同用例不同性能维度可以配置不同的告警规则。当发生劣化时及时将对应的信息抛到对应业务群中进行告警。

descript

2.5 管理端展示

2.5.1 防劣化看板

防劣化看板支持查看指定时间、分支、测试用例和场景下的每个 commit 的状态以及各项性能数据,并可以快速标记 commit,支持与任意 commit 的性能数据做对比。

descript

2.5.2 分支性能报告

防劣化系统会对所有需求分支的每一次 push 进行性能测试,然后与对应的主干 Commit 性能数据进行对比生成性能报告。能直观的看到需求分支的性能变化,当性能发生劣化的时候也能直观的看到是从哪个 Commit 开始引入劣化问题,方便问题排查。

descript

测试报告有多种状态,比如”等待数据上报”、”自动审批通过”、”自动审批不通过” 等:

descript

descript

descript

当测试报告”自动审批不通过” 时,也会标注出是哪些指标不通过,便于开发者迅速定位问题:

descript

2.5.3 测试用例管理

基础所提供的主流程测试用例必然是无法覆盖手 Q 所有的场景,因此提供开放能力,支持各业务方的开发、测试自主提供测试用例。在防劣化平台上进行配置测试,测试完毕后自动根据配置生成相应的性能看板。

descript

descript

同时对正在运行的测试用例进行成功率监控,低于一定的成功率将进行告警。如业务方在一段时间内没有处理告警,会将其临时下架避免资源浪费。

descript

2.6 整体架构

descript

收益与总结

Hodor 上线后收益显著,研发效率大幅提升!

descript

通过将问题发现和解决左移到开发阶段,可以有效防止问题漏出到线上导致大盘数据劣化。如某次提交导致主干启动耗时上涨,基于防劣化系统可精准快速定位到代码提交者。

descript

Hodor 系统还在不断迭代中,2024 年还拓展了 QQ 桌面客户端,并在运行效率方面持续优化。目前防劣化系统已经落地了 QQ 各平台。

防劣化系统从 0 到 1 迭代了将近三年的时间。从验证可行性跑通最小闭环,到搭建群控机架一次次为集群扩容,实属不易。其中涉及到大量的方案讨论甚至推翻,很多思路和实现细节是业界找不到公开方案的,只能自己摸索。

在建设过程中我们遇到了不少很底层的问题需要与厂商沟通,比如与 Apple 的技术专家们线上和线下交流过程中也学到了不少,在此也感谢 Apple。

客户端的性能稳定性防劣化是一个很复杂的话题,而且只有体量足够大的业务才会面临更多的挑战。正因为我们面对的很多问题业界都无先例可循,所以也期待行业内后续有更多的分享和交流。同时,我们也期望 Hodor 不仅在性能稳定性方面发挥作用,未来也会把手 Q 研发效能的各项指标集成进来。

-End-

iOS 半屏浮层展示列表并支持滑动的快速实现

产品提了一个需求, 有一个屏幕浮层, 里面有个列表, 滑动列表时候整个分层要能跟着在屏幕上挪动, 同时浮层上方还有个拖动区域, 也要能拖曳, 我们知道 UITableView 自己本身可以相应滚动手势, 列表上方可以通过一个UIPanGestureRecognizer手势来拖曳, 但是怎么让两者之间相互适配, 更丝滑的实现滚动效果, 还是花了很多时间, 处理的细节比较多, 比如:

  1. 顶部的滚动手势没必要处理跟 UITableView 的手势冲突, 按照下面处理也可以跟手
    1. UITableView 响应手势的时候, 可以通过 scrollViewDidScroll 设置整个浮层的 y 值
    2. 列表之外通过 UIPanGestureRecognizer响应手势, 并在合适的时候设置 UITableView 的 contentOffsetY.
  2. 快速滑动可以直接动画, 不用跟手了.
    1. UITableView 通过 hs_scrollViewWillEndDraggingvelocity 来识别快速滑动
    2. UIPanGestureRecognizer手势结束的时候, 可以通过[gesture velocityInView:self]判断速度, 来识别快速滑动
  3. 滑动结束的时候, 如果是快速滑动, 根据速度方向, 以及当前浮层的 y 值快速动画到下一个状态, 否则直接根据y 值进入下一个状态
  4. UITableView 响应手势的时候, 如果浮层 y值可以动, 就通过[tableView setContentOffset: animated:NO];不让 UITableView 产生滚动, 当浮层不能上下动再让UITableView 产生滚动
  5. 需要通过UITableView的回调判定用户当前是否还在操作屏幕, 此时要跟手

大概效果如下:
Jun-28-2024 19-41-02

封装实现:WYUtils/Classes/UIView/UIView+WYHalfScreen.h

参考demo Example/SubItems/View/WYHalfScreenAlertViewController.m

接入

  1. 导入相关文件并 import 头文件
  2. 给浮层 View 实现相关代理, 并添加相应手势的 View 以及 UITableView
    1
    2
    3
    4
    5
    6
    @interface WYHalfScreenView () <UITableViewDataSource, UITableViewDelegate, UIGestureRecognizerDelegate>

    @property (nonatomic, strong) UIView *topPanView;
    @property (nonatomic, strong) UITableView *tableView;

    @end
  3. 将上面定义的几个组件绑定下, 并设定屏幕高度/ 半屏高度/最大高度
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    - (void)configGesture
    {
    WY_WEAK_SELF(self);
    [self hs_commonInitialWithDragView:self.topPanView
    dragDelegate:self
    scrollView:self.tableView
    hideCompletion:^{
    WY_STRONG_SELF(self);
    [self hide];
    }];
    self.hs_viewHeight = WY_SCREEN_HEIGHT;
    self.hs_minHeight = [self.class actionSheetViewMinHeight];
    self.hs_maxHeight = [self.class actionSheetViewMaxHeight];
    }
  4. 实现 UIScrollViewDelegate 并调用几个方法
    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
    #pragma mark - UITableViewDelegate

    - (void)scrollViewDidScroll:(UIScrollView *)scrollView
    {
    if (scrollView == self.tableView)
    {
    [self hs_scrollViewDidScroll:scrollView];
    }
    }

    - (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView
    {
    if (scrollView == self.tableView)
    {
    [self hs_scrollViewWillBeginDragging:scrollView];
    }
    }

    - (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView
    {
    if (scrollView == self.tableView)
    {
    [self hs_scrollViewDidEndDecelerating:scrollView];
    }
    }

    - (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate
    {
    if (scrollView == self.tableView)
    {
    [self hs_scrollViewDidEndDragging:scrollView willDecelerate:decelerate];
    }
    }

    - (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset
    {
    if (scrollView == self.tableView)
    {
    [self hs_scrollViewWillEndDragging:scrollView
    withVelocity:velocity
    targetContentOffset:targetContentOffset];
    }
    }
  5. 实现展开动画以及结束动画
    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
    - (void)show
    {
    // 不能直接在 VC 上弹出, 二级页边缘手势处理不了, 跟歌曲AS一样直接在 UIWindow 上弹出吧
    UIWindow *window = [[UIApplication sharedApplication] keyWindow];
    [window addSubview:self];
    WY_WEAK_SELF(self);
    [self hs_show:^{
    WY_STRONG_SELF(self);
    [self setBackgroundColor:[[UIColor blackColor] colorWithAlphaComponent:0.4]];
    } completion:^{

    }];
    }

    - (void)hide
    {
    WY_WEAK_SELF(self);
    [self hs_hide:^{
    WY_STRONG_SELF(self);
    [self setBackgroundColor:[UIColor clearColor]];
    } completion:^{
    WY_STRONG_SELF(self);
    [self removeFromSuperview];
    }];
    }

iOS 列表元素曝光

开发需求中, 产品经常希望知道列表中的某个元素是不是曝光了, 但是曝光的规则又会给的很复杂, 比如

  1. 页面出现且数据加载好 需要曝光
  2. 刷新页面 数据没变不曝光
  3. 进入下一个页面, 再回来, 曝光
  4. 滑动列表, 元素不可见, 然后滑回来, 又变的可见了, 需要曝光
  5. 退后台 再回到应用, 需要曝光

甚至不同的产品不同的业务这里曝光规则都会不一样, 如果每个业务自己去单独实现, 又会有很多重复代码.

我封装了一个简单曝光的分类
使用方法可以参考: Example/SubItems/ScrollView/WYScrollExposureViewController.m

简单来说

  1. 导入文件, 并 import 头文件
  2. 实现 UIScrollViewDelegate, 滚动的时候调用 simple_markScrollViewDidScroll方法, 每调用一次, 都会检查屏幕上是否存在没有曝光的列表元素, 然后曝光
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    // 看需要可以打开, 或者做下限频, 比如一秒调用一次
    //- (void)scrollViewDidScroll:(UIScrollView *)scrollView
    //{
    // [self stoppedScrollingForExposure];
    //}

    - (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView
    {
    [self stoppedScrollingForExposure];
    }

    - (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate
    {
    if (!decelerate)
    {
    [self stoppedScrollingForExposure];
    }
    }

    - (void)stoppedScrollingForExposure
    {
    [self.tableView simple_markScrollViewDidScroll];
    }
  3. 在各个业务时机调用 simple_exposureWhenNeed 以及 simple_markInvisibleForExposure
    1. 页面出现时候, 如页面刷新时, 页面出现时, 从二级页返回时, 后台回到前台, 调用 simple_exposureWhenNeed
    2. 页面消失时, 如退出页面, 进到二级页, 退到后台, 页面刷新之前 调用simple_markInvisibleForExposure
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      #pragma mark - Exposure

      - (void)exposureWhenNeed
      {
      [self.tableView simple_exposureWhenNeed];
      }

      - (void)markInvisibleForExposure
      {
      [self.tableView simple_markInvisibleForExposure];
      }
  4. cell 需要实现 WYScrollViewItemSimpleExposureProtocol 委托
    1. 需要曝光则返回一个不重复字符串, 不需要曝光则返回空字符串即可
    2. 收到 simple_exposureStatInfo 回调时, 自行做上报即可
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      #pragma mark - WYScrollViewItemSimpleExposureProtocol
      - (NSString *)simple_exposureIdentifier
      {
      return WY_AVOID_NIL_STRING(self.data);
      }

      - (void)simple_exposureStatInfo:(NSString *)identifier;
      {
      NSLog(@"Exposure %@", identifier);
      }

iOS View 跟随陀螺仪移动或者翻转效果实现

需求需要实现一个轻摇手机时候, 页面背景左右挪动, 同时图片翻转的效果, 一般我们会直接用到CMMotionManager, CMMotionManager 是 Core Motion Framework 的一部分,它可以让我们方便地获取设备的运动数据,如加速计数据、陀螺仪数据、磁力计数据等。
但是 这个工具中能使用的传感器东西比较多, 最终我是使用 CMAcceleration.gravity 设备受到的重力的加速度 这个数据来完成的.

实现效果

最后实现的效果如下
Jun-28-2024 16-11-24

实现代码WYUtils/Classes/UIView/UIView+GravityMotion.h

使用方法如下

1
2
3
4
5
6
7

// 水平移动
[backImageView gm_startWithMaxHorizontalOffset:200 maxVerticalOffset:100 maxAngleDx:60 maxAngleDy:60];

// x/y 轴翻转
[redView gm_startRotateWithXAngel:0 yAngel:0 maxXAngel:60 maxYAngel:60];

完整使用见Example/SubItems/View/WYGravityMotionViewController.m

遇到的坑

x/y 轴翻转 View 被截断问题

直接设置 redView.layer.transform 遇到了 下图这样被截断的问题.
IMG_2914

在 redView 外加了一层 UIView 就能解决问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
// 中间需要插入一个 superview, 不然就被截断了
UIView *tmpView = [UIView new];
tmpView.backgroundColor = UIColor.clearColor;
[self.view addSubview:tmpView];
[tmpView mas_makeConstraints:^(MASConstraintMaker *make) {
make.centerX.equalTo(self.view);
make.top.equalTo(self.view).offset(100);
make.width.height.mas_equalTo(200);
}];
UIView *redView = [[UIView alloc] init];
redView.backgroundColor = UIColor.redColor;
[tmpView addSubview:redView];
redView.frame = CGRectMake((WY_SCREEN_WIDTH - 300)/2, 100, 300, 300);
redView.layer.cornerRadius = 10;
[redView mas_makeConstraints:^(MASConstraintMaker *make) {
make.edges.equalTo(tmpView);
}];
[redView gm_startRotateWithXAngel:0 yAngel:0 maxXAngel:60 maxYAngel:60];
}

图片翻转的时候变模糊了

3D 翻转的时候, 设置好属性shouldRasterize以及rasterizationScale如下:

1
2
3
4
5
6
7
8
9
10
11
- (void)gm_rotateWithGravityX:(float)gravityX
gravityY:(float)gravityY
{
CATransform3D transform = CATransform3DIdentity;
transform.m34 = -1 / 500.0; // 景深, 估计值, 500~3000 都可以, 按情况调一下
transform = CATransform3DRotate(transform, gravityX * M_PI_2, 0, 1, 0);
transform = CATransform3DRotate(transform, gravityY * M_PI_2, 1, 0, 0);
self.layer.transform = transform;
self.layer.shouldRasterize = YES;
self.layer.rasterizationScale = [UIScreen mainScreen].scale;
}
❌