渲染与性能
IOSurface 图集、Metal 三缓冲与共享 DisplayLink。
主题范围:Telegram-iOS 的渲染管线、Metal 与动画贴纸性能。
以下结论来自 f1dd7a2dbd02cbbf513e75d5695d8d36d1cf5838 的本地源码。条目按意外程度和工程价值排序。MetalEngine 中未找到 CAMetalLayer。这里的共享载体实际是 IOSurface 图集。
1. 用一张 IOSurface 图集承载多个独立图层
做了什么:引擎把多个 MetalEngineSubjectLayer 分配到同一 IOSurface。每个图层只用 contentsRect 展示自己的子矩形。引擎只在图集变更时重设 layer.contents。
为什么这样做:常规做法会为每个效果层创建纹理或图层内容。这里把小效果打包到共享表面。它减少表面数、纹理分配和 Core Animation 提交压力。
关键代码摘录
if let surface = self.addSurface(width: surfaceWidth, height: surfaceHeight,
pixelFormat: renderSpec.pixelFormat) {
if let allocation = surface.allocateIfPossible(
renderingParameters: renderingParameters
) {
layer.surfaceAllocation = allocation
layer.contentsRect = allocation.effectivePhase.contentsRect
updatedSurfaceId = allocation.surfaceId
}
}
// ...
if previousSurfaceId != updatedSurfaceId {
if let updatedSurfaceId {
layer.contents = self.surfaces[updatedSurfaceId]?.ioSurface
} else {
layer.contents = nil
}
}代价与风险:图集分配器复杂。分配失败会清空图层内容。代码没有使用私有 CAMetalLayer API。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 报告摘要
- 怎么做到:allocator 记录相位与
surfaceId,变更时重设contents。 - 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
2. 为 EDR 大层放弃固定图集,避免 32 MB 空耗
做了什么:普通小层优先放入固定表面。尺寸达到 1024 的层改用贴合尺寸的表面。rgba16Float 也使用对齐后的专用尺寸。
为什么这样做:HDR/EDR 像素占用更大。固定 2048 图集会让单个展开效果占用约 32 MB。此分支避免为少量大层支付整张图集的成本。
关键代码摘录
} else if renderingParameters.allocationWidth >= 1024 ||
renderingParameters.allocationHeight >= 1024 {
let surfaceWidth = max(1024, alignUp(
renderingParameters.allocationWidth * 2, alignment: 64
))
let surfaceHeight = max(512, alignUp(
renderingParameters.allocationHeight, alignment: 64
))
if let surface = self.addSurface(width: surfaceWidth,
height: surfaceHeight,
pixelFormat: renderSpec.pixelFormat) {
if let allocation = surface.allocateIfPossible(
renderingParameters: renderingParameters
) {
layer.surfaceAllocation = allocation
layer.contentsRect = allocation.effectivePhase.contentsRect
}
}
}
// EDR is normally used by one expanded subject. Avoid a full 32 MB atlas.代价与风险:这是一组启发式阈值。不同设备和效果尺寸可能需要重新调参。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
3. 只在首个脏 subject 时请求一次绘制
做了什么:引擎为每个 subject 赋稳定内部编号。它在一个显示周期内去重脏 subject。只有队列从空变为非空时才调用根层的 setNeedsDisplay()。
为什么这样做:常规逐视图刷新会产生重复显示请求。此处把多个状态变更合并进一次 Metal command buffer。
关键代码摘录
let isFirst = self.updatedSubjectIds.isEmpty
if !self.updatedSubjectIds.contains(internalId) {
self.updatedSubjectIds.append(internalId)
self.updatedSubjects.append(SubjectReference(subject: subject))
}
if isFirst {
self.setNeedsDisplay()
}代价与风险:subject 的 update 必须能处理合并后的最终状态。频繁线性 contains 在极大量 subject 时有额外开销。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 报告摘要
- 怎么做到:
updatedSubjectIds判空/包含逻辑合并。 - 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
4. 三缓冲私有纹理,并在 GPU 完成后归还
做了什么:PooledTexture 预建三个 GPU 私有纹理。每次渲染取一个空闲纹理。引擎在 command buffer 完成回调中再调用 free()。
为什么这样做:纹理不会在 GPU 仍读取时被复用。三缓冲允许 CPU 连续提交多个周期。.private 也避免 CPU 可见存储的额外成本。
关键代码摘录
self.textures = (0 ..< 3).compactMap { _ -> Texture? in
let descriptor = MTLTextureDescriptor.texture2DDescriptor(
pixelFormat: spec.pixelFormat.metalFormat,
width: spec.width, height: spec.height, mipmapped: false
)
descriptor.storageMode = .private
descriptor.usage = [.shaderRead, .shaderWrite]
return device.makeTexture(descriptor: descriptor).map(Texture.init)
}
// ...
for texture in self.textures {
if !texture.isInUse {
texture.isInUse = true
context.freeResourcesOnCompletion.append(texture)
return TexturePlaceholder(placeholer: placeholder, spec: self.spec)
}
}代价与风险:池满时返回 nil,调用方必须允许本帧不渲染。每个规格固定占用三份显存。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 报告摘要
- 怎么做到:
usage [.shaderRead,.shaderWrite]与完成队列回收。 - 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
5. 用 Objective-C 方法替换规避 MTLBinaryArchive 崩溃
做了什么:保存 Metal 二进制归档前,代码临时登记目标路径。它替换 FileManager 的公开 Objective-C 方法。失败时,它把 NSError 保活到 serialize(to:) 返回。
为什么这样做:源码注释记录了 iOS 26.3 与 macOS 27 的崩溃。Metal 在另一个线程中使用已被释放的错误对象。普通 do/catch 无法拦住该崩溃。
关键代码摘录
static func serialize(_ archive: MTLBinaryArchive, to url: URL) throws {
guard let failedMoveErrors = FailedMoveErrors.shared else {
throw UnprotectedError()
}
failedMoveErrors.beginKeeping(forTarget: url)
defer { failedMoveErrors.endKeeping(forTarget: url) }
try archive.serialize(to: url)
}
// ...
let selector = NSSelectorFromString(
"replaceItemAtURL:withItemAtURL:backupItemName:options:resultingItemURL:error:"
)
guard let method = class_getInstanceMethod(FileManager.self, selector) else { return nil }
let original = unsafeBitCast(method_getImplementation(method), to: ReplaceItemImplementation.self)
method_setImplementation(method, imp_implementationWithBlock(
unsafeBitCast(replacement, to: AnyObject.self)
))代价与风险:这是进程级方法替换。它依赖公开 selector 的 ABI。系统实现变化会使保护失效。它未调用私有 API。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 报告摘要
- 怎么做到:
method_setImplementation注入 block,外层保护文件移动。 - REA 第 17 项
- 版本差异REA 二进制REA 运行时观测
13.0 二进制:
_TtC11MetalEngine16FailedMoveErrors;完整 selector 字符串replaceItemAtURL:withItemAtURL:backupItemName:options:resultingItemURL:error:;导入method_setImplementationmacOS 26.7:启动 40 秒内未调用
method_setImplementation结论:版本差异:2025-07 的 TelegramSwift 源码中没有,13.0 已带入
6. 用保存标记和 rename(2) 防止缓存崩溃循环
做了什么:管线突发创建后延迟一秒合并保存。保存前创建 .saving 标记。它先写随机临时文件,再用原子 rename(2) 替换旧归档。
为什么这样做:不完整归档和反复崩溃会拖垮后续启动。标记可识别上次保存中断。原子替换保证读取方只能看到旧文件或新文件。
关键代码摘录
self.isSaveScheduled = true
self.saveQueue.asyncAfter(deadline: .now() + 1.0) { [weak self] in
self?.save()
}
// ...
let saveMarkerUrl = MetalPipelineCache.saveMarkerUrl(archiveUrl: url)
guard FileManager.default.createFile(atPath: saveMarkerUrl.path, contents: nil) else { return }
defer { let _ = try? FileManager.default.removeItem(at: saveMarkerUrl) }
let temporaryUrl = url.deletingLastPathComponent().appendingPathComponent(
UUID().uuidString + ".tmp"
)
try MetalBinaryArchiveSerialization.serialize(archive, to: temporaryUrl)
if rename(temporaryUrl.path, url.path) != 0 {
let _ = try? FileManager.default.removeItem(at: temporaryUrl)
}代价与风险:文件系统错误会放弃本次缓存。首次启动或版本变化仍要重新编译管线。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
7. 一个定时器驱动整组 DCT 动画,并按电量降到 30 FPS
做了什么:一个 DCTMultiAnimationRendererImpl 用一个 timer 驱动所有活动项。没有活动项时立刻销毁 timer。低电量或 CPU 核数不超过 2 时,frameSkip 为 2。
为什么这样做:每个贴纸各自建 timer 会造成唤醒风暴。这里在低资源设备固定使用约 30 FPS。common mode 还能让滚动时动画继续推进。
关键代码摘录
private(set) var isPlaying: Bool = false {
didSet {
if self.isPlaying != oldValue {
if self.isPlaying {
if self.displayTimer == nil {
let frameInterval = Double(self.frameSkip) / 60.0
let displayTimer = Foundation.Timer(
timeInterval: frameInterval, target: TimerTarget { [weak self] in
self?.animationTick(frameInterval: frameInterval)
}, selector: #selector(TimerTarget.timerEvent),
userInfo: nil, repeats: true
)
self.displayTimer = displayTimer
RunLoop.main.add(displayTimer, forMode: .common)
}
} else if let displayTimer = self.displayTimer {
self.displayTimer = nil
displayTimer.invalidate()
}
}
}
}代价与风险:timer 不保证严格垂直同步。低电量模式下动画时间精度和流畅度下降。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
8. 相同贴纸只解码一次,再扇出到多个弱目标
做了什么:默认 uniqueId 为 0。相同资源和尺寸命中同一个 ItemAnimationContext。该上下文把同一帧分发给多个弱引用目标。调用者可用 unique 强制独立时间线。
为什么这样做:表情网格常重复出现同一个动画。常规节点会重复解码和推进帧。这里让多个图层共用一次帧读取。
关键代码摘录
let itemKey = ItemKey(id: itemId, width: Int(size.width),
height: Int(size.height), uniqueId: uniqueId)
let itemContext: ItemAnimationContext
if let current = self.itemContexts[itemKey] {
itemContext = current
} else {
let queueAffinity = self.nextQueueAffinity
self.nextQueueAffinity += 1
itemContext = ItemAnimationContext(cache: cache,
queueAffinity: queueAffinity, itemId: itemId, size: size,
useYuvA: useYuvA, fetch: fetch, stateUpdated: { [weak self] in
self?.updateIsPlaying()
})
self.itemContexts[itemKey] = itemContext
}
let index = itemContext.targets.add(Weak(target))
itemContext.updateAddedTarget(target: target)代价与风险:共享项默认同相位。需要不同相位时必须设置 unique。弱目标的清理路径更复杂。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
9. 用两条固定后台队列并行解码,再批量回主线程提交
做了什么:渲染器按稳定的 queueAffinity 将帧任务分到两条固定队列。两个分区完成后,代码再把所有图层更新闭包投到主线程。
为什么这样做:它限制并发为两路,而不是为每个贴纸派发工作。稳定归属也避免同一动画的访问在多个线程间迁移。
关键代码摘录
let tasks0 = tasks.filter { $0.queueAffinity % 2 == 0 }
let tasks1 = tasks.filter { $0.queueAffinity % 2 == 1 }
let allTasks = [tasks0, tasks1]
let taskCompletions = Atomic<[Int: [() -> Void]]>(value: [:])
let queues: [Queue] = [ItemAnimationContext.queue0, ItemAnimationContext.queue1]
for i in 0 ..< 2 {
let partTasks = allTasks[i]
queues[i].async {
var completions: [() -> Void] = []
for task in partTasks { completions.append(task.task()) }
let _ = taskCompletions.modify { current in
var current = current
current[i] = completions
return current
}
}
}代价与风险:一条队列的慢任务会拖延整批主线程提交。设备核心数更多时也只用两路。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
10. DCT 缓存以 YUVA 平面存储关键帧和差分帧
做了什么:缓存把帧转为 Y、U、V、Alpha 四个 DCT 系数平面。非关键帧存储相对前帧的系数差。每帧带关键帧标记和四个平面长度。
为什么这样做:RGBA 全帧缓存会放大磁盘和内存带宽。相邻贴纸帧通常变化较小。差分 DCT 让大量表情动画适合本地缓存。
关键代码摘录
let isKeyframe: Bool
if !isFirstFrame && !insertKeyframe {
isKeyframe = false
// previous + delta = current
// delta = current - previous
yuvaSurface.toCoefficients(target: differenceCoefficients)
differenceCoefficients.subtract(other: previousCoefficients)
dctCoefficients = differenceCoefficients
} else {
isKeyframe = true
yuvaSurface.toCoefficients(target: dctCoefficients)
}
// ...
try compressedWriter.writeUInt32(isKeyframe ? 1 : 0)
for i in 0 ..< 4 {
let dctPlane: DctCoefficientPlane
// yPlane, uPlane, vPlane, aPlane
try compressedWriter.writeUInt32(UInt32(dctPlane.data.count))
try dctPlane.data.withUnsafeBytes { bytes in
try compressedWriter.write(bytes: bytes.baseAddress!.assumingMemoryBound(to: UInt8.self),
count: bytes.count)
}
}代价与风险:从差分帧恢复依赖前序帧。损坏或随机跳转时恢复成本更高。非 arm64 代码强制所有帧为关键帧。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
11. 以“单飞”请求共享缓存构建,并在无人订阅时中止
做了什么:缓存键相同的读取者挂到同一个 ItemContext。只有首个读取者启动抓取和压缩。后续读取者订阅结果。最后一个订阅者离开时,代码 dispose 抓取并把 writer 标为取消。
为什么这样做:快速滚动时,同一贴纸会被多个 cell 请求。常规实现会重复下载、解码和写盘。这里也把写入轮转到有限的 fetchQueues。
关键代码摘录
let key = ItemKey(id: sourceId, width: Int(size.width), height: Int(size.height))
let itemContext: ItemContext
var beginFetch = false
if let current = self.itemContexts[key] {
itemContext = current
} else {
itemContext = ItemContext()
self.itemContexts[key] = itemContext
beginFetch = true
}
let index = itemContext.subscribers.add(updateResult)
if beginFetch {
let fetchQueueIndex = self.nextFetchQueueIndex
self.nextFetchQueueIndex += 1
guard let writer = AnimationCacheItemWriterImpl(
queue: self.fetchQueues[fetchQueueIndex % self.fetchQueues.count],
allocateTempFile: self.allocateTempFile, completion: { /* ... */ }
) else { return EmptyDisposable }代价与风险:首个请求失败会影响同键订阅者。订阅和取消时序必须保持在缓存队列内。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
12. 从更高分辨率缓存即时派生较小缓存
做了什么:请求尺寸缺失时,缓存可读取更高分辨率版本。它直接缩放四个 YUVA 平面,并写回所需尺寸的 DCT 缓存。
为什么这样做:它避免重新读取原动画和重新栅格化 Lottie。YUV 平面缩放也避开 ARGB 往返转换。
关键代码摘录
for _ in 0 ..< higherResolutionItem.numFrames {
try writer.addYUV(with: { yuva in
guard let frame = higherResolutionItem.advance(
advance: .frames(1),
requestedFormat: .yuva(rowAlignment: yuva.yPlane.rowAlignment)
) else { return nil }
switch frame.frame.format {
case let .yuva(y, u, v, a):
yuva.yPlane.copyScaled(fromPlane: y)
yuva.uPlane.copyScaled(fromPlane: u)
yuva.vPlane.copyScaled(fromPlane: v)
yuva.aPlane.copyScaled(fromPlane: a)
default: return nil
}
return frame.frame.duration
}, proposedWidth: width, proposedHeight: height, insertKeyframe: true)
}代价与风险:派生过程会占用 CPU、I/O 和额外空间。代码把派生版本全部设为关键帧,文件可能更大。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
13. DrawingContext 将位图缓冲的所有权直接交给 CGImage
做了什么:DrawingContext 采用统一的设备行对齐。它让 CGContext 直接画入 ASCGImageBuffer。生成 CGImage 时,createDataProviderAndInvalidate() 转移缓冲所有权。
为什么这样做:常规 UIImage 上下文常产生中间数据复制。这里绘制、CGImage provider 与释放共享同一内存块。
关键代码摘录
self.bytesPerRow = bytesPerRow ??
DeviceGraphicsContextSettings.shared.bytesPerRow(forWidth: Int(scaledSize.width))
self.length = self.bytesPerRow * Int(scaledSize.height)
guard let imageBuffer = ASCGImageBuffer(length: UInt(self.length)) else { return nil }
self.imageBuffer = imageBuffer
guard let context = CGContext(
data: self.imageBuffer.mutableBytes,
width: Int(self.scaledSize.width), height: Int(self.scaledSize.height),
bitsPerComponent: DeviceGraphicsContextSettings.shared.bitsPerComponent,
bytesPerRow: self.bytesPerRow,
space: colorSpace ?? DeviceGraphicsContextSettings.shared.colorSpace,
bitmapInfo: self.bitmapInfo.rawValue,
releaseCallback: nil, releaseInfo: nil
) else { return nil }
self.context = context
// ...
let dataProvider = self.imageBuffer.createDataProviderAndInvalidate()代价与风险:hasGeneratedImage 后禁止再次生成。调用方不能继续写这个 context。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
14. 用原位字节创建带 IOSurface 属性的 CVPixelBuffer
做了什么:代码不复制 DrawingContext 的位图。它将同一地址传给 CVPixelBufferCreateWithBytes,并以 release callback 管理 ASCGImageBuffer 生命周期。options 请求 IOSurface 属性。
为什么这样做:这个路径把 CPU 栅格化结果直接交给视频或图层路径。它避免先建 CGImage、再绘制进新的像素缓冲。
关键代码摘录
let ioSurfaceProperties = NSMutableDictionary()
let options = NSMutableDictionary()
options.setObject(ioSurfaceProperties,
forKey: kCVPixelBufferIOSurfacePropertiesKey as NSString)
var pixelBuffer: CVPixelBuffer?
CVPixelBufferCreateWithBytes(nil, Int(self.scaledSize.width),
Int(self.scaledSize.height), kCVPixelFormatType_32BGRA,
self.bytes, self.bytesPerRow, { pointer, _ in
if let pointer = pointer {
Unmanaged<ASCGImageBuffer>.fromOpaque(pointer).release()
}
}, Unmanaged.passRetained(self.imageBuffer).toOpaque(),
options as CFDictionary, &pixelBuffer)
self.hasGeneratedImage = true
return pixelBuffer代价与风险:像素格式和字节序必须严格匹配。缓冲在 CVPixelBuffer 释放前不能被重用。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
15. GIF 网格使用一个显示链路,并只预读两帧
做了什么:多个 GIF 视频目标共用 SharedDisplayLinkDriver。每个目标最多补到两帧。所有预读都在 sharedQueue 完成。isRendering 阻止同一时间重复发起解码批次。
为什么这样做:每个 GIF 自己持有显示链路和深队列会消耗 CPU 与内存。两帧是平滑度和内存之间的明确上限。
关键代码摘录
if !self.targetContexts.isEmpty {
if self.displayLink == nil {
self.displayLink = SharedDisplayLinkDriver.shared.add { [weak self] _ in
self?.updateRendering()
}
}
} else {
self.displayLink = nil
}
// ...
if targetState.sampleBuffers.count < 2 {
renderIds[id] = 2 - targetState.sampleBuffers.count
}
// ...
BatchVideoRenderingContext.sharedQueue.async {
for (id, (count, readingContext)) in readingContexts {
for _ in 0 ..< count {
if let sampleBuffer = readingContext.unsafeGet()?.advance() {
sampleBuffers[id]?.append(sampleBuffer)
}
}
}
}代价与风险:解码队列是共享瓶颈。复杂视频可能导致其他网格项补帧变慢。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
16. 用墙钟重放 CMSampleBuffer,而不信任各层播放时钟
做了什么:批处理器用 CACurrentMediaTime() 作为共同墙钟。它比较已显示帧的 PTS 和真实经过时间。PTS 回退时按上一帧 duration 判断循环边界。
为什么这样做:每个 AVSampleBufferDisplayLayer 独立排程会让 GIF 网格相位不一致。该代码统一在一处决定何时消费下一帧。
关键代码摘录
let sampleBuffer = self.sampleBuffers[0]
let sampleBufferPts = CMSampleBufferGetPresentationTimeStamp(sampleBuffer).seconds
let sampleBufferDuration = CMSampleBufferGetDuration(sampleBuffer).seconds
if let lastRenderedFrame = self.lastRenderedFrame {
let elapsedTime = timestamp - lastRenderedFrame.timestamp
let ptsDifference = sampleBufferPts - lastRenderedFrame.pts
if ptsDifference < 0.0 {
if elapsedTime >= lastRenderedFrame.duration {
self.lastRenderedFrame = (timestamp, sampleBufferPts, sampleBufferDuration)
self.sampleBuffers.removeFirst()
return sampleBuffer
}
} else if elapsedTime >= ptsDifference {
self.lastRenderedFrame = (timestamp, sampleBufferPts, sampleBufferDuration)
self.sampleBuffers.removeFirst()
return sampleBuffer
}
}代价与风险:这是软件时钟。主线程卡顿后会延迟显示而非精确追帧。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
17. 小尺寸 GIF 禁用去块滤波,并给不可解码文件熔断
做了什么:网格 GIF 约以 93 pt 显示时,解码器跳过去块滤波。若整个文件未产出有效帧,或出现不应发生的等待状态,代码永久标记失败。
为什么这样做:去块滤波在小缩略图中难以察觉,却是批量解码成本的大头。永久熔断也避免无法支持的 10-bit 文件反复从头解码并卡住共享队列。
关键代码摘录
let reader = FFMpegFileReader(
source: .file(self.dataPath),
useHardwareAcceleration: false,
// These render into ~93pt tiles, where deblocking is not visible.
skipLoopFilter: true,
selectedStream: .mediaType(.video),
seek: nil, maxReadablePts: nil
)
// ...
case .endOfStream:
if !self.readerDidProduceFrame {
self.isFailed = true
break outer
}
self.reader = nil
case .waitingForMoreData:
self.isFailed = true
break outer代价与风险:小尺寸视频的块效应可能略增加。失败视频只保留静态缩略图,直到对象重建。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
18. Lottie 缓存固定每隔一帧栅格化一次
做了什么:Lottie 缓存无条件使用 frameSkip = 2。它把两帧的时间合并进一张位图。代码也在后台 writer 队列生成,并随时响应取消。
为什么这样做:这把昂贵的矢量栅格化和后续存储帧数直接减半。对贴纸的视觉收益通常高于 60 FPS 的收益。
关键代码摘录
let decompressedData = TGGUnzipData(data, 2 * 1024 * 1024) ?? data
guard let animation = makeLottieInstance(
data: decompressedData, fitzModifier: .none,
colorReplacements: nil, cacheKey: "", settings: lottieSettings
) else {
writer.finish()
return
}
let frameSkip: Int = 2
let frameDuration = Double(frameSkip) / Double(animation.frameRate)
for i in stride(from: 0, through: animation.frameCount - 1, by: frameSkip) {
if writer.isCancelled { break }
writer.add(with: { surface in
animation.renderFrame(with: i, into: surface.argb,
width: Int32(surface.width), height: Int32(surface.height),
bytesPerRow: Int32(surface.bytesPerRow))
return frameDuration
}, proposedWidth: width, proposedHeight: height,
insertKeyframe: i == 0 || keyframeOnly)
}代价与风险:高帧率 Lottie 会降到一半帧率。源码中保留了基于 CPU 的旧策略,但该策略已注释。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
19. Subcodec 把多个精灵合为一条编码流,再切回各自区域
做了什么:相同尺寸的贴纸进入 SurfaceGroup。每个贴纸占一个 slot。任一 slot 前进后,surface 只发出一条合成 NAL 流。代码解码最近帧,再按 color/alpha 区域切回每个目标。
为什么这样做:这是把 N 个小动画编码和解码批量化。它可避免每个贴纸各自维护一套视频流和解码器。
关键代码摘录
var anyAdvanced = false
for (slot, context) in self.spriteContexts {
guard context.isPlaying else { continue }
context.remainingDuration -= advanceTimestamp
if context.remainingDuration <= 0.0 {
surface.advanceSprite(atSlot: Int32(slot))
context.currentFrameIndex += 1
if context.currentFrameIndex >= context.metadata.frameCount {
context.currentFrameIndex = 0
}
context.remainingDuration = context.metadata.frameDurations[context.currentFrameIndex]
anyAdvanced = true
}
}
if !anyAdvanced { return }
self.accumulatedNalData = Data()
try? surface.emitFrameIfNeeded { [weak self] data in
self?.accumulatedNalData.append(data)
}
guard let frames = try? decoder.decodeStream(self.accumulatedNalData) else { return }代价与风险:一个贴纸变化会触发整组编码和解码。当前 extractCGImage 使用 CPU 逐像素 YCbCr 转换。超大组可能反而不划算。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
20. 尘埃效果把粒子状态更新和绘制都留在 GPU
做了什么:每个尘埃 item 在 GPU buffer 中保存粒子。compute shader 初始化并更新状态。渲染阶段用六顶点四边形做实例绘制,不把粒子位置读回 CPU。
为什么这样做:CPU 逐粒子更新再逐层绘制会造成大量对象和提交。实例绘制把整块遮罩的粒子压为一次 draw call。
关键代码摘录
let threadgroupSize = MTLSize(width: 32, height: 1, depth: 1)
let threadgroupCount = MTLSize(
width: (particleRowCount * particleColumnCount + threadgroupSize.width - 1)
/ threadgroupSize.width,
height: 1, depth: 1
)
computeEncoder.setBuffer(particleBuffer.buffer, offset: 0, index: 0)
if !item.particleBufferIsInitialized {
item.particleBufferIsInitialized = true
computeEncoder.setComputePipelineState(state.computePipelineStateInitializeParticle)
computeEncoder.dispatchThreadgroups(threadgroupCount,
threadsPerThreadgroup: threadgroupSize)
}
computeEncoder.setComputePipelineState(state.computePipelineStateUpdateParticle)
computeEncoder.dispatchThreadgroups(threadgroupCount,
threadsPerThreadgroup: threadgroupSize)
// ...
encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 6,
instanceCount: particleCount)代价与风险:粒子数来自 item 尺寸。大面积效果会快速扩大 buffer 和实例数。时间步还受 UIView.animationDurationFactor() 影响。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
21. JPEG 解码选择 TurboJPEG 快速路径,并让 vImage 不分块
做了什么:代码把行宽对齐到 32 字节。它要求 TurboJPEG 使用快速 DCT 和快速上采样。随后用两次 vImage 操作转为应用所需的通道顺序。
为什么这样做:此路径避免通用 UIImage 解码的不可控懒解码。快速标志优先吞吐。kvImageDoNotTile 避免内部切块的调度成本。
关键代码摘录
int sourceBytesPerRow = (3 * width + 31) & ~0x1F;
int targetBytesPerRow = (4 * width + 31) & ~0x1F;
unsigned char *buffer = (uint8_t *)malloc(sourceBytesPerRow * height);
tjDecompress2(_jpegDecompressor, _compressedImage, jpegSize,
buffer, width, sourceBytesPerRow, height, TJPF_RGB,
TJFLAG_FASTDCT | TJFLAG_FASTUPSAMPLE);
vImage_Buffer source = { .width = width, .height = height,
.rowBytes = sourceBytesPerRow, .data = buffer };
vImage_Buffer target = { .width = width, .height = height,
.rowBytes = targetBytesPerRow, .data = targetBuffer };
vImageConvert_RGB888toARGB8888(&source, nil, 0xff, &target,
false, kvImageDoNotTile);
const uint8_t permuteMap[4] = {3, 2, 1, 0};
vImagePermuteChannels_ARGB8888(&target, &permuteTarget, permuteMap,
kvImageDoNotTile);代价与风险:FASTDCT 与快速上采样会牺牲少量 JPEG 质量。内存峰值同时持有 RGB、ARGB 和重排缓冲。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
22. 复用 AVSampleBufferDisplayLayer,并禁用隐式动作
做了什么:takeSampleBufferLayer() 从全局原子池取图层。归还时在主线程 flush、清图并重置 transform。子类对所有 layer action 返回空 CAAction。
为什么这样做:滚动媒体 cell 频繁创建视频图层很昂贵。对象池减少分配。空 action 防止复用和属性改动触发无意义的 Core Animation 隐式动画。
关键代码摘录
private final class SampleBufferLayerImplNullAction: NSObject, CAAction {
@objc func run(forKey event: String, object anObject: Any,
arguments dict: [AnyHashable : Any]?) {}
}
private final class SampleBufferLayerImpl: AVSampleBufferDisplayLayer {
override func action(forKey event: String) -> CAAction? {
return SampleBufferLayerImplNullAction()
}
}
private let pool = Atomic<[AVSampleBufferDisplayLayer]>(value: [])
// ...
Queue.mainQueue().async {
layer.flushAndRemoveImage()
layer.setAffineTransform(CGAffineTransform.identity)
let _ = pool.modify { list in
var list = list
list.append(layer)
return list
}
}代价与风险:池是无上限数组。代码刻意在模拟器不回收进池。调用方必须让包装对象正确释放。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。