Telegram 技巧档案UIKIT · APPKIT
iOS · UIKit

渲染与性能

IOSurface 图集、Metal 三缓冲与共享 DisplayLink。

主题范围:Telegram-iOS 的渲染管线、Metal 与动画贴纸性能。

以下结论来自 f1dd7a2dbd02cbbf513e75d5695d8d36d1cf5838 的本地源码。条目按意外程度和工程价值排序。MetalEngine 中未找到 CAMetalLayer。这里的共享载体实际是 IOSurface 图集。

1. 用一张 IOSurface 图集承载多个独立图层

#08-01Telegram-iOSUIKit报告精选 08-1源码分析Core AnimationMetal / GPU

做了什么:引擎把多个 MetalEngineSubjectLayer 分配到同一 IOSurface。每个图层只用 contentsRect 展示自己的子矩形。引擎只在图集变更时重设 layer.contents。

为什么这样做:常规做法会为每个效果层创建纹理或图层内容。这里把小效果打包到共享表面。它减少表面数、纹理分配和 Core Animation 提交压力。

关键代码摘录

MetalEngine.swift · L920–L976
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。

证据链 · #08-01iOS 未做二进制验证
源码分析
已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
报告摘要
怎么做到:allocator 记录相位与 surfaceId,变更时重设 contents。
二进制验证
未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。

2. 为 EDR 大层放弃固定图集,避免 32 MB 空耗

做了什么:普通小层优先放入固定表面。尺寸达到 1024 的层改用贴合尺寸的表面。rgba16Float 也使用对齐后的专用尺寸。

为什么这样做:HDR/EDR 像素占用更大。固定 2048 图集会让单个展开效果占用约 32 MB。此分支避免为少量大层支付整张图集的成本。

关键代码摘录

MetalEngine.swift · L920–L975
} 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.

代价与风险:这是一组启发式阈值。不同设备和效果尺寸可能需要重新调参。

证据链 · #08-02iOS 未做二进制验证
源码分析
已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
二进制验证
未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。

3. 只在首个脏 subject 时请求一次绘制

#08-03Telegram-iOSUIKit报告精选 08-2源码分析

做了什么:引擎为每个 subject 赋稳定内部编号。它在一个显示周期内去重脏 subject。只有队列从空变为非空时才调用根层的 setNeedsDisplay()。

为什么这样做:常规逐视图刷新会产生重复显示请求。此处把多个状态变更合并进一次 Metal command buffer。

关键代码摘录

MetalEngine.swift · L1043–L1071
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 时有额外开销。

证据链 · #08-03iOS 未做二进制验证
源码分析
已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
报告摘要
怎么做到:updatedSubjectIds 判空/包含逻辑合并。
二进制验证
未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。

4. 三缓冲私有纹理,并在 GPU 完成后归还

#08-04Telegram-iOSUIKit报告精选 08-3源码分析私有 APIMetal / GPU

做了什么:PooledTexture 预建三个 GPU 私有纹理。每次渲染取一个空闲纹理。引擎在 command buffer 完成回调中再调用 free()。

为什么这样做:纹理不会在 GPU 仍读取时被复用。三缓冲允许 CPU 连续提交多个周期。.private 也避免 CPU 可见存储的额外成本。

关键代码摘录

MetalEngine.swift · L217–L268
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,调用方必须允许本帧不渲染。每个规格固定占用三份显存。

证据链 · #08-04iOS 未做二进制验证
源码分析
已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
报告摘要
怎么做到:usage [.shaderRead,.shaderWrite] 与完成队列回收。
二进制验证
未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。

5. 用 Objective-C 方法替换规避 MTLBinaryArchive 崩溃

#08-05Telegram-iOSUIKit报告精选 08-4源码分析REA 二进制REA 运行时观测私有 APIMetal / GPU

做了什么:保存 Metal 二进制归档前,代码临时登记目标路径。它替换 FileManager 的公开 Objective-C 方法。失败时,它把 NSError 保活到 serialize(to:) 返回。

为什么这样做:源码注释记录了 iOS 26.3 与 macOS 27 的崩溃。Metal 在另一个线程中使用已被释放的错误对象。普通 do/catch 无法拦住该崩溃。

关键代码摘录

MetalBinaryArchiveSerialization.swift · L22–L90
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。

证据链 · #08-05版本差异
源码分析
已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
报告摘要
怎么做到:method_setImplementation 注入 block,外层保护文件移动。
REA 第 17 项
版本差异REA 二进制REA 运行时观测

13.0 二进制:_TtC11MetalEngine16FailedMoveErrors;完整 selector 字符串 replaceItemAtURL:withItemAtURL:backupItemName:options:resultingItemURL:error:;导入 method_setImplementation

macOS 26.7:启动 40 秒内未调用 method_setImplementation

结论:版本差异:2025-07 的 TelegramSwift 源码中没有,13.0 已带入

6. 用保存标记和 rename(2) 防止缓存崩溃循环

#08-06Telegram-iOSUIKit源码分析性能与内存

做了什么:管线突发创建后延迟一秒合并保存。保存前创建 .saving 标记。它先写随机临时文件,再用原子 rename(2) 替换旧归档。

为什么这样做:不完整归档和反复崩溃会拖垮后续启动。标记可识别上次保存中断。原子替换保证读取方只能看到旧文件或新文件。

关键代码摘录

MetalPipelineCache.swift · L233–L309
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)
}

代价与风险:文件系统错误会放弃本次缓存。首次启动或版本变化仍要重新编译管线。

证据链 · #08-06iOS 未做二进制验证
源码分析
已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
二进制验证
未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。

7. 一个定时器驱动整组 DCT 动画,并按电量降到 30 FPS

做了什么:一个 DCTMultiAnimationRendererImpl 用一个 timer 驱动所有活动项。没有活动项时立刻销毁 timer。低电量或 CPU 核数不超过 2 时,frameSkip 为 2。

为什么这样做:每个贴纸各自建 timer 会造成唤醒风暴。这里在低资源设备固定使用约 30 FPS。common mode 还能让滚动时动画继续推进。

关键代码摘录

DCTMultiAnimationRendererImpl.swift · L893–L934
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 不保证严格垂直同步。低电量模式下动画时间精度和流畅度下降。

证据链 · #08-07iOS 未做二进制验证
源码分析
已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
二进制验证
未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。

8. 相同贴纸只解码一次,再扇出到多个弱目标

做了什么:默认 uniqueId 为 0。相同资源和尺寸命中同一个 ItemAnimationContext。该上下文把同一帧分发给多个弱引用目标。调用者可用 unique 强制独立时间线。

为什么这样做:表情网格常重复出现同一个动画。常规节点会重复解码和推进帧。这里让多个图层共用一次帧读取。

关键代码摘录

DCTMultiAnimationRendererImpl.swift · L686–L743
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。弱目标的清理路径更复杂。

证据链 · #08-08iOS 未做二进制验证
源码分析
已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
二进制验证
未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。

9. 用两条固定后台队列并行解码,再批量回主线程提交

做了什么:渲染器按稳定的 queueAffinity 将帧任务分到两条固定队列。两个分区完成后,代码再把所有图层更新闭包投到主线程。

为什么这样做:它限制并发为两路,而不是为每个贴纸派发工作。稳定归属也避免同一动画的访问在多个线程间迁移。

关键代码摘录

DCTMultiAnimationRendererImpl.swift · L1026–L1077
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
        }
    }
}

代价与风险:一条队列的慢任务会拖延整批主线程提交。设备核心数更多时也只用两路。

证据链 · #08-09iOS 未做二进制验证
源码分析
已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
二进制验证
未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。

10. DCT 缓存以 YUVA 平面存储关键帧和差分帧

做了什么:缓存把帧转为 Y、U、V、Alpha 四个 DCT 系数平面。非关键帧存储相对前帧的系数差。每帧带关键帧标记和四个平面长度。

为什么这样做:RGBA 全帧缓存会放大磁盘和内存带宽。相邻贴纸帧通常变化较小。差分 DCT 让大量表情动画适合本地缓存。

关键代码摘录

DCTAnimationCacheImpl.swift · L366–L445
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 代码强制所有帧为关键帧。

证据链 · #08-10iOS 未做二进制验证
源码分析
已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
二进制验证
未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。

11. 以“单飞”请求共享缓存构建,并在无人订阅时中止

做了什么:缓存键相同的读取者挂到同一个 ItemContext。只有首个读取者启动抓取和压缩。后续读取者订阅结果。最后一个订阅者离开时,代码 dispose 抓取并把 writer 标为取消。

为什么这样做:快速滚动时,同一贴纸会被多个 cell 请求。常规实现会重复下载、解码和写盘。这里也把写入轮转到有限的 fetchQueues。

关键代码摘录

DCTAnimationCacheImpl.swift · L1343–L1423
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 }

代价与风险:首个请求失败会影响同键订阅者。订阅和取消时序必须保持在缓存队列内。

证据链 · #08-11iOS 未做二进制验证
源码分析
已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
二进制验证
未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。

12. 从更高分辨率缓存即时派生较小缓存

做了什么:请求尺寸缺失时,缓存可读取更高分辨率版本。它直接缩放四个 YUVA 平面,并写回所需尺寸的 DCT 缓存。

为什么这样做:它避免重新读取原动画和重新栅格化 Lottie。YUV 平面缩放也避开 ARGB 往返转换。

关键代码摘录

DCTAnimationCacheImpl.swift · L1139–L1186
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 和额外空间。代码把派生版本全部设为关键帧,文件可能更大。

证据链 · #08-12iOS 未做二进制验证
源码分析
已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
二进制验证
未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。

13. DrawingContext 将位图缓冲的所有权直接交给 CGImage

做了什么:DrawingContext 采用统一的设备行对齐。它让 CGContext 直接画入 ASCGImageBuffer。生成 CGImage 时,createDataProviderAndInvalidate() 转移缓冲所有权。

为什么这样做:常规 UIImage 上下文常产生中间数据复制。这里绘制、CGImage provider 与释放共享同一内存块。

关键代码摘录

GenerateImage.swift · L619–L700
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。

证据链 · #08-13iOS 未做二进制验证
源码分析
已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
二进制验证
未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。

14. 用原位字节创建带 IOSurface 属性的 CVPixelBuffer

#08-14Telegram-iOSUIKit源码分析Metal / GPU

做了什么:代码不复制 DrawingContext 的位图。它将同一地址传给 CVPixelBufferCreateWithBytes,并以 release callback 管理 ASCGImageBuffer 生命周期。options 请求 IOSurface 属性。

为什么这样做:这个路径把 CPU 栅格化结果直接交给视频或图层路径。它避免先建 CGImage、再绘制进新的像素缓冲。

关键代码摘录

GenerateImage.swift · L702–L724
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 释放前不能被重用。

证据链 · #08-14iOS 未做二进制验证
源码分析
已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
二进制验证
未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。

15. GIF 网格使用一个显示链路,并只预读两帧

做了什么:多个 GIF 视频目标共用 SharedDisplayLinkDriver。每个目标最多补到两帧。所有预读都在 sharedQueue 完成。isRendering 阻止同一时间重复发起解码批次。

为什么这样做:每个 GIF 自己持有显示链路和深队列会消耗 CPU 与内存。两帧是平滑度和内存之间的明确上限。

关键代码摘录

BatchVideoRenderingContext.swift · L268–L372
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)
            }
        }
    }
}

代价与风险:解码队列是共享瓶颈。复杂视频可能导致其他网格项补帧变慢。

证据链 · #08-15iOS 未做二进制验证
源码分析
已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
二进制验证
未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。

16. 用墙钟重放 CMSampleBuffer,而不信任各层播放时钟

#08-16Telegram-iOSUIKit源码分析文本与输入性能与内存

做了什么:批处理器用 CACurrentMediaTime() 作为共同墙钟。它比较已显示帧的 PTS 和真实经过时间。PTS 回退时按上一帧 duration 判断循环边界。

为什么这样做:每个 AVSampleBufferDisplayLayer 独立排程会让 GIF 网格相位不一致。该代码统一在一处决定何时消费下一帧。

关键代码摘录

BatchVideoRenderingContext.swift · L53–L87
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
    }
}

代价与风险:这是软件时钟。主线程卡顿后会延迟显示而非精确追帧。

证据链 · #08-16iOS 未做二进制验证
源码分析
已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
二进制验证
未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。

17. 小尺寸 GIF 禁用去块滤波,并给不可解码文件熔断

做了什么:网格 GIF 约以 93 pt 显示时,解码器跳过去块滤波。若整个文件未产出有效帧,或出现不应发生的等待状态,代码永久标记失败。

为什么这样做:去块滤波在小缩略图中难以察觉,却是批量解码成本的大头。永久熔断也避免无法支持的 10-bit 文件反复从头解码并卡住共享队列。

关键代码摘录

BatchVideoRenderingContext.swift · L90–L162
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

代价与风险:小尺寸视频的块效应可能略增加。失败视频只保留静态缩略图,直到对象重建。

证据链 · #08-17iOS 未做二进制验证
源码分析
已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
二进制验证
未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。

18. Lottie 缓存固定每隔一帧栅格化一次

做了什么:Lottie 缓存无条件使用 frameSkip = 2。它把两帧的时间合并进一张位图。代码也在后台 writer 队列生成,并随时响应取消。

为什么这样做:这把昂贵的矢量栅格化和后续存储帧数直接减半。对贴纸的视觉收益通常高于 60 FPS 的收益。

关键代码摘录

LottieAnimationCache.swift · L10–L60
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 的旧策略,但该策略已注释。

证据链 · #08-18iOS 未做二进制验证
源码分析
已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
二进制验证
未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。

19. Subcodec 把多个精灵合为一条编码流,再切回各自区域

做了什么:相同尺寸的贴纸进入 SurfaceGroup。每个贴纸占一个 slot。任一 slot 前进后,surface 只发出一条合成 NAL 流。代码解码最近帧,再按 color/alpha 区域切回每个目标。

为什么这样做:这是把 N 个小动画编码和解码批量化。它可避免每个贴纸各自维护一套视频流和解码器。

关键代码摘录

SubcodecMultiAnimationRendererImpl.swift · L206–L288
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 转换。超大组可能反而不划算。

证据链 · #08-19iOS 未做二进制验证
源码分析
已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
二进制验证
未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。

20. 尘埃效果把粒子状态更新和绘制都留在 GPU

#08-20Telegram-iOSUIKit源码分析Metal / GPU

做了什么:每个尘埃 item 在 GPU buffer 中保存粒子。compute shader 初始化并更新状态。渲染阶段用六顶点四边形做实例绘制,不把粒子位置读回 CPU。

为什么这样做:CPU 逐粒子更新再逐层绘制会造成大量对象和提交。实例绘制把整块遮罩的粒子压为一次 draw call。

关键代码摘录

DustEffectLayer.swift · L295–L362
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() 影响。

证据链 · #08-20iOS 未做二进制验证
源码分析
已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
二进制验证
未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。

21. JPEG 解码选择 TurboJPEG 快速路径,并让 vImage 不分块

#08-21Telegram-iOSUIKit源码分析性能与内存

做了什么:代码把行宽对齐到 32 字节。它要求 TurboJPEG 使用快速 DCT 和快速上采样。随后用两次 vImage 操作转为应用所需的通道顺序。

为什么这样做:此路径避免通用 UIImage 解码的不可控懒解码。快速标志优先吞吐。kvImageDoNotTile 避免内部切块的调度成本。

关键代码摘录

MozjpegBinding.mm · L735–L811
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 和重排缓冲。

证据链 · #08-21iOS 未做二进制验证
源码分析
已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
二进制验证
未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。

22. 复用 AVSampleBufferDisplayLayer,并禁用隐式动作

#08-22Telegram-iOSUIKit源码分析Core Animation性能与内存

做了什么:takeSampleBufferLayer() 从全局原子池取图层。归还时在主线程 flush、清图并重置 transform。子类对所有 layer action 返回空 CAAction。

为什么这样做:滚动媒体 cell 频繁创建视频图层很昂贵。对象池减少分配。空 action 防止复用和属性改动触发无意义的 Core Animation 隐式动画。

关键代码摘录

SampleBufferPool.swift · L6–L65
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
    }
}

代价与风险:池是无上限数组。代码刻意在模拟器不回收进池。调用方必须让包装对象正确释放。

证据链 · #08-22iOS 未做二进制验证
源码分析
已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
二进制验证
未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。

本页目录