Telegram 技巧档案UIKIT · APPKIT
iOS · UIKit

媒体、Stories 与个人页

粒子行为、零拷贝视频纹理与个人页下拉画廊。

本主题范围:Telegram-iOS 的 Stories、相机、媒体编辑、DrawingUI、个人页头像、GalleryUI、Gifts 与 Premium 的富媒体交互和渲染实现。

结论:此范围最激进的做法有两类。第一类直接触及私有 Core Animation 或 UIKit 实现。第二类把预览、滤镜和 3D 动画做成可控的 GPU 管线。未在本次已读代码中确认 Stories 的“立方体翻页”实现。因此本文不把它列为结论。

1. 用运行时调用私有 CAEmitterBehavior

#10-01Telegram-iOSUIKit源码分析私有 API运行时 hackCore Animation

位置说明:Telegram-iOS:submodules/Display/Source/UIKitUtils.swift#L935-L943;调用处 Telegram-iOS:submodules/TelegramUI/Components/Gifts/GiftViewScreen/Sources/GiftAuctionBidScreen.swift#L3263-L3284

做了什么:代码动态组装类名 CAEmitterBehavior。代码动态组装 selector behaviorWithType:。随后它经 KVC 为粒子设置 colorOverLife 和 simpleAttractor 行为。

为什么这样做:公开 CAEmitterLayer 只能配置基础粒子属性。该私有行为可使颜色随生命周期变化。该私有行为也可让粒子被吸引器牵引。

关键代码摘录

UIKitUtils.swift · L935–L943 等 2 处
public extension CAEmitterCell {
    static func createEmitterBehavior(type: String) -> NSObject {
        let selector = ["behaviorWith", "Type:"].joined(separator: "")
        let behaviorClass = NSClassFromString(["CA", "Emitter", "Behavior"].joined(separator: "")) as! NSObject.Type
        let behaviorWithType = behaviorClass.method(for: NSSelectorFromString(selector))!
        let castedBehaviorWithType = unsafeBitCast(
            behaviorWithType,
            to: (@convention(c) (Any?, Selector, Any?) -> NSObject).self
        )
        return castedBehaviorWithType(behaviorClass, NSSelectorFromString(selector), type)
    }
}
// GiftAuctionBidScreen.swift
let staticColorBehavior = CAEmitterCell.createEmitterBehavior(type: "colorOverLife")
staticColorBehavior.setValue(staticColors, forKey: "colors")
staticEmitter.setValue([staticColorBehavior], forKey: "emitterBehaviors")
let attractor = CAEmitterCell.createEmitterBehavior(type: "simpleAttractor")
attractor.setValue("attractor", forKey: "name")
self.staticEmitterLayer.setValue([attractor], forKey: "emitterBehaviors")

解混淆确认:数组拼接直接得到 CAEmitterBehavior。["behaviorWith", "Type:"] 直接得到 behaviorWithType:。NSClassFromString、NSSelectorFromString 和 unsafeBitCast 明确证实了调用目标。

代价与风险:这是私有 API。它存在 App Review 拒绝风险。系统实现变化会导致强制解包崩溃。

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

2. 个人页用私有 _verticalVelocity 把第二次下拉变成头像画廊

#10-02Telegram-iOSUIKit报告精选 10-1源码分析私有 API手势与滚动

做了什么:代码把 "_" 和 "verticalVelocity" 拼成 _verticalVelocity。它通过 KVC 读取 UIScrollView 的私有纵向速度。首次下拉到 -32 时展开头像。展开后再次下拉会触发画廊。

为什么这样做:UIScrollViewDelegate 不公开当前滚动速度。panGestureRecognizer.velocity(in:) 不能完整替代内部滚动减速速度。该值让头像展开和后续手势更连续。

关键代码摘录

PeerInfoScreen.swift · L5984–L6021
func scrollViewDidScroll(_ scrollView: UIScrollView) {
    guard !self.ignoreScrolling else {
        return
    }
    self.headerNode.headerEdgeEffectContainer.center = CGPoint(
        x: 0.0, y: scrollView.contentOffset.y
    )
    if !self.state.isEditing {
        if self.canAddVelocity {
            self.previousVelocityM1 = self.previousVelocity
            if let value = (scrollView.value(
                forKey: (["_", "verticalVelocity"] as [String]).joined()
            ) as? NSNumber)?.doubleValue {
                self.previousVelocity = CGFloat(value)
            }
        }
        let offsetY = self.scrollNode.view.contentOffset.y
        var shouldBeExpanded: Bool?
        if offsetY <= -32.0 && scrollView.isDragging && scrollView.isTracking {
            if let peer = self.data?.peer, self.chatLocation.threadId == nil,
               peer.smallProfileImage != nil && self.state.updatingAvatar == nil {
                shouldBeExpanded = true
            }
        }

解混淆确认:字符串数组经 .joined() 得到 _verticalVelocity。读取目标就是 scrollView.value(forKey:)。

代价与风险:这是对 UIKit 私有 KVC 键的访问。它有审核风险。新系统可让 KVC 抛异常或返回空值。

证据链 · #10-02iOS 未做二进制验证
源码分析
已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
报告摘要
怎么做到:拼接 @"_"+"verticalVelocity" 并 valueForKey: 读取。
二进制验证
未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。

3. 圆视频把 NV12 的两个平面零拷贝映射到 Metal

#10-03Telegram-iOSUIKit报告精选 10-2源码分析Metal / GPU性能与内存

做了什么:相机输入的 NV12 数据不经过 CGImage。Y 平面映射为 .r8Unorm。CbCr 平面映射为 .rg8Unorm。计算着色器随后完成颜色转换、镜像和裁切。

为什么这样做:CPU 转图会增加内存带宽和延迟。双平面纹理让 CVPixelBuffer 直接成为 GPU 输入。这适合实时圆视频预览和录制。

关键代码摘录

RoundVideoFrameProcessor.swift · L467–L516
private func makeInputTextures(pixelBuffer: CVPixelBuffer)
-> (y: CVMetalTexture, cbcr: CVMetalTexture)? {
    var yTexture: CVMetalTexture?
    let yStatus = CVMetalTextureCacheCreateTextureFromImage(
        kCFAllocatorDefault, self.textureCache, pixelBuffer, nil,
        .r8Unorm, CVPixelBufferGetWidthOfPlane(pixelBuffer, 0),
        CVPixelBufferGetHeightOfPlane(pixelBuffer, 0), 0, &yTexture
    )
    var cbcrTexture: CVMetalTexture?
    let cbcrStatus = CVMetalTextureCacheCreateTextureFromImage(
        kCFAllocatorDefault, self.textureCache, pixelBuffer, nil,
        .rg8Unorm, CVPixelBufferGetWidthOfPlane(pixelBuffer, 1),
        CVPixelBufferGetHeightOfPlane(pixelBuffer, 1), 1, &cbcrTexture
    )
    guard yStatus == kCVReturnSuccess, cbcrStatus == kCVReturnSuccess,
          let yTexture, let cbcrTexture else {
        return nil
    }
    return (yTexture, cbcrTexture)
}

代价与风险:必须处理支持的像素格式。代码仅接受两平面 full-range 或 video-range NV12。纹理缓存失败时会丢弃当前帧。

证据链 · #10-03iOS 未做二进制验证
源码分析
已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
报告摘要
怎么做到:为每平面创建 CVMetalTexture,失败即丢帧。
二进制验证
未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。

4. 圆视频先低分辨率模糊,再在同一命令缓冲区合成

#10-04Telegram-iOSUIKit报告精选 10-3源码分析Metal / GPU

做了什么:代码以 30 fps 离散选择装饰图集帧。代码先写入小尺寸纹理。代码对小纹理执行模糊。后续合成器读取模糊纹理、原始 YUV、图集和水印。

为什么这样做:直接模糊全尺寸相机帧成本很高。先降采样会显著减少卷积工作量。同一 MTLCommandBuffer 也保留了 GPU 操作顺序。

关键代码摘录

RoundVideoFrameProcessor.swift · L332–L385
let effectiveAnimationTime = animationTime.isFinite
    ? max(0.0, animationTime) : 0.0
let frameIndex = Int(floor(effectiveAnimationTime * 30.0))
    % RoundVideoDecorationAnimation.frameCount
uniforms.decoration = SIMD4<Float>(
    Float(frameIndex), Float(RoundVideoDecorationAnimation.columns),
    Float(RoundVideoDecorationAnimation.rows), 0.0
)
guard let downsampleEncoder = commandBuffer.makeComputeCommandEncoder() else {
    return
}
downsampleEncoder.setComputePipelineState(decoration.downsamplePipelineState)
downsampleEncoder.setTexture(primaryY, index: 0)
downsampleEncoder.setTexture(primaryCbCr, index: 1)
downsampleEncoder.setTexture(decoration.downsampleTexture, index: 4)
Self.dispatch(encoder: downsampleEncoder,
              pipelineState: decoration.downsamplePipelineState,
              width: RoundVideoMetalDecoration.blurWidth,
              height: RoundVideoMetalDecoration.blurHeight)
downsampleEncoder.endEncoding()
decoration.blur.encode(commandBuffer: commandBuffer,
                       sourceTexture: decoration.downsampleTexture,
                       destinationTexture: decoration.blurredTexture)

代价与风险:装饰动画固定为 30 fps。小纹理模糊可能产生细节损失。该路径依赖设备支持 Metal。

证据链 · #10-04iOS 未做二进制验证
源码分析
已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
报告摘要
怎么做到:downsample→blur.encode→合成 pass。
二进制验证
未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。

5. 圆视频用有上限的 CVPixelBufferPool 施加背压

#10-05Telegram-iOSUIKit报告精选 10-4源码分析

做了什么:输出池最少保留四个缓冲。代码主动申请四个缓冲后立即释放引用。池的分配阈值为六。isRendering 还保证同一时刻只处理一帧。

为什么这样做:预热可避免首帧和切相机时临时分配。阈值会阻止编码或 GPU 落后时无限堆积帧。主动丢帧通常优于实时相机的内存膨胀。

关键代码摘录

RoundVideoFrameProcessor.swift · L218–L272
self.outputPool = outputPool
self.outputPoolAuxiliaryAttributes = [
    kCVPixelBufferPoolAllocationThresholdKey as String: 6
] as CFDictionary
var preallocatedBuffers: [CVPixelBuffer] = []
for _ in 0 ..< 4 {
    var pixelBuffer: CVPixelBuffer?
    if CVPixelBufferPoolCreatePixelBufferWithAuxAttributes(
        kCFAllocatorDefault,
        outputPool,
        self.outputPoolAuxiliaryAttributes,
        &pixelBuffer
    ) == kCVReturnSuccess, let pixelBuffer {
        preallocatedBuffers.append(pixelBuffer)
    }
}
preallocatedBuffers.removeAll()
func render(
    primary: VideoFrameSource,
    secondary: VideoFrameSource?,
    mixFactor: Float,
    animationTime: Double,
    completion: @escaping (ProcessedVideoFrame?) -> Void
) {
    guard !self.isRendering else {
        return
    }

代价与风险:繁忙时会丢弃新帧。弱设备可能看到不连续预览。isRendering 必须在完成回调中正确复位。

证据链 · #10-05iOS 未做二进制验证
源码分析
已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
报告摘要
怎么做到:kCVPixelBufferPoolAllocationThresholdKey:6 创建像素缓冲。
二进制验证
未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。

6. 处理完成前显式保活像素缓冲和纹理

#10-06Telegram-iOSUIKit报告精选 10-5源码分析私有 APIMetal / GPU

做了什么:代码保留输入、输出 CVPixelBuffer 和 CVMetalTexture。它直到 GPU 命令完成后才结束这些对象的生命周期。回调也切回相机的 mediaQueue。

为什么这样做:Swift 的局部变量可在 GPU 完成前释放。仅提交命令缓冲不保证 Core Video 对象仍存活。这里避免了偶发黑帧、访问失效纹理和竞态。

关键代码摘录

RoundVideoFrameProcessor.swift · L406–L434
let retainedPixelBuffers = [
    primary.pixelBuffer, effectiveSecondary.pixelBuffer, outputPixelBuffer
]
let retainedTextures = [
    primaryTextures.y, primaryTextures.cbcr,
    secondaryTextures.y, secondaryTextures.cbcr, outputTexture
]
self.isRendering = true
commandBuffer.addCompletedHandler { [weak self] commandBuffer in
    let processedFrame: ProcessedVideoFrame?
    if commandBuffer.status == .completed {
        processedFrame = ProcessedVideoFrame(pixelBuffer: outputPixelBuffer)
    } else {
        processedFrame = nil
    }
    self?.mediaQueue.async { [weak self] in
        guard let self else {
            return
        }
        self.isRendering = false
        withExtendedLifetime(retainedPixelBuffers) {
            withExtendedLifetime(retainedTextures) {
                completion(processedFrame)
            }
        }
    }
}
commandBuffer.commit()

代价与风险:每个在途帧都占用若干缓冲。单帧背压限制了该成本。无明显私有 API 风险。

证据链 · #10-06iOS 未做二进制验证
源码分析
已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
报告摘要
怎么做到:捕获 CVPixelBuffer/CVMetalTexture,回主媒体队列复位状态。
二进制验证
未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。

7. 媒体编辑器用分块直方图生成局部增强 LUT

#10-07Telegram-iOSUIKit源码分析私有 APIMetal / GPU

做了什么:代码为每个网格块计算 MPSImageHistogram。代码将每块直方图写进共享私有缓冲。计算着色器再生成一组 LUT。

为什么这样做:全局自动增强会让局部阴影或高光失真。分块直方图相当于局部对比度增强。GPU 直方图和 LUT 也避免了在调节滑块时回读像素。

关键代码摘录

EnhanceRenderPass.swift · L154–L188
let histogramSize = calculation.histogramSize(forSourceFormat: input.pixelFormat)
for i in 0 ..< lutCount {
    let col = i % gridSize.width
    let row = i / gridSize.width
    calculation.clipRectSource = MTLRegionMake2D(
        col * tileSize.width, row * tileSize.height,
        tileSize.width, tileSize.height
    )
    calculation.encode(to: commandBuffer, sourceTexture: input,
                       histogram: histogramBuffer,
                       histogramOffset: i * histogramSize)
}
guard let computeCommandEncoder = commandBuffer.makeComputeCommandEncoder() else {
    return nil
}
guard let pipelineState = self.pipelineState else {
    return nil
}
var parameters = MediaEditorEnhanceLUTGeneratorParameters(
    histogramBins: UInt32(binCount),
    clipLimit: UInt32(clipLimitValue),
    totalPixelCountPerTile: UInt32(tileSize.width * tileSize.height),
    numberOfLUTs: UInt32(lutCount)
)
computeCommandEncoder.setComputePipelineState(pipelineState)
computeCommandEncoder.setBuffer(histogramBuffer, offset: 0, index: 0)
computeCommandEncoder.setBytes(&parameters,
    length: MemoryLayout<MediaEditorEnhanceLUTGeneratorParameters>.size, index: 1)

代价与风险:小网格会显得过度处理。每帧创建直方图缓冲仍有成本。该代码需要 Metal Performance Shaders。

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

8. 增强滤镜复用私有输出纹理,并跳过无用清屏

#10-08Telegram-iOSUIKit源码分析私有 APIMetal / GPU性能与内存

做了什么:滤镜只在首次执行时创建输出纹理。纹理使用 .private 存储。渲染通道使用 .dontCare 作为加载动作。

为什么这样做:编辑预览会重复渲染同尺寸媒体。反复分配纹理会抖动内存和帧时间。片元着色器会覆盖每个像素,所以清屏和加载旧内容没有价值。

关键代码摘录

EnhanceRenderPass.swift · L199–L245
func process(input: MTLTexture, lookupTexture: MTLTexture,
             value: simd_float1, gridSize: simd_float2,
             device: MTLDevice, commandBuffer: MTLCommandBuffer) -> MTLTexture? {
    self.setupVerticesBuffer(device: device)
    let width = input.width
    let height = input.height
    if self.cachedTexture == nil {
        let textureDescriptor = MTLTextureDescriptor()
        textureDescriptor.textureType = .type2D
        textureDescriptor.width = width
        textureDescriptor.height = height
        textureDescriptor.pixelFormat = input.pixelFormat
        textureDescriptor.storageMode = .private
        textureDescriptor.usage = [.shaderRead, .shaderWrite, .renderTarget]
        guard let texture = device.makeTexture(descriptor: textureDescriptor) else {
            return input
        }
        self.cachedTexture = texture
    }
    let renderPassDescriptor = MTLRenderPassDescriptor()
    renderPassDescriptor.colorAttachments[0].texture = self.cachedTexture!
    renderPassDescriptor.colorAttachments[0].loadAction = .dontCare
    renderPassDescriptor.colorAttachments[0].storeAction = .store

代价与风险:若输入尺寸或格式在对象生命周期内改变,缓存可能不匹配。该片段本身未展示重建逻辑。此假设未验证。

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

9. 切换嵌入式相机时冻结旧预览并叠加圆形毛玻璃

做了什么:切相机前先取 snapshotView。代码把快照留在原位。代码再以翻转动画显示圆形深色模糊层。切换完成后分别淡出毛玻璃和快照。

为什么这样做:AVCaptureSession 切前后摄常有黑帧或曝光跳变。静态快照使内容连续。模糊层还掩盖了快照不能反映新相机的事实。

关键代码摘录

DrawingStickerEntityView.swift · L558–L595
public func beginCameraSwitch() {
    guard let cameraPreviewView = self.cameraPreviewView,
          self.cameraBlurView == nil else {
        return
    }
    if let snapshot = cameraPreviewView.snapshotView(afterScreenUpdates: false) {
        self.cameraSnapshotView = snapshot
        self.addSubview(snapshot)
    }
    let blurView = BlurView(effect: nil)
    blurView.clipsToBounds = true
    blurView.frame = self.bounds
    blurView.layer.cornerRadius = self.bounds.width / 2.0
    self.addSubview(blurView)
    UIView.transition(with: self, duration: 0.4,
                      options: [.transitionFlipFromLeft, .curveEaseOut]) {
        blurView.effect = UIBlurEffect(style: .dark)
    }
    self.cameraBlurView = blurView
}
public func commitCameraSwitch() {
    if let cameraBlurView = self.cameraBlurView {
        self.cameraBlurView = nil
        UIView.animate(withDuration: 0.4) {
            cameraBlurView.effect = nil
        }

代价与风险:快照只代表最后一帧。切换慢时用户会看到冻结画面。模糊效果也增加了 GPU 合成成本。

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

10. GalleryUI 用双过渡视图同步缩略图与全屏圆角

做了什么:画廊创建两个过渡视图。一个加入上层 transition surface。一个留在项目自身。回调收到源尺寸、目标尺寸、屏幕圆角和 progress。

为什么这样做:单个视图无法同时正确服务跨父视图坐标系和项目内部遮挡。双视图避免转场期间出现空洞。显式传递硬件屏幕圆角可让全屏端贴合设备。

关键代码摘录

UniversalVideoGalleryItem.swift · L2565–L2589
let transitionContentSourceFrame = contentTransition.sourceView.convert(
    contentTransition.sourceRect, to: transitionContentSuperview
)
let transitionContentDestinationFrame = self.view.convert(
    self.view.bounds, to: transitionContentSuperview
)
let transitionContentSelfSourceFrame = contentTransition.sourceView.convert(
    contentTransition.sourceRect, to: self.view
)
let transitionContentSelfDestinationFrame = self.view.convert(
    self.view.bounds, to: self.view
)
let screenCornerRadius: CGFloat = validLayout.layout.deviceMetrics.screenCornerRadius
transitionContentView.frame = transitionContentSourceFrame
contentTransition.updateView(transitionContentView,
    GalleryItemScrubberTransition.Content.TransitionState(
        sourceSize: transitionContentSourceFrame.size,
        destinationSize: transitionContentDestinationFrame.size,
        destinationCornerRadius: screenCornerRadius, progress: 0.0
    ), .immediate)
transitionSelfContentView.frame = transitionContentSelfSourceFrame
contentTransition.updateView(transitionSelfContentView,
    GalleryItemScrubberTransition.Content.TransitionState(
        sourceSize: transitionContentSelfSourceFrame.size,
        destinationSize: transitionContentSelfDestinationFrame.size,
        destinationCornerRadius: screenCornerRadius, progress: 0.0
    ), .immediate)
let transition: ContainedViewLayoutTransition = .animated(duration: 0.25, curve: .spring)
transition.updateFrame(view: transitionContentView,
                       frame: transitionContentDestinationFrame)

代价与风险:实际圆角插值由 updateView 闭包的提供方完成。该提供方不在本次已读片段中。因此插值公式未验证。两个临时视图会增加转场复杂度。

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

11. Stories 退场时把克隆层递归设回屏幕光栅倍率

做了什么:fixScale 会将层及所有子层的 rasterizationScale 设为 UIScreenScale。退场时故事内容和目标头像各有一份过渡克隆。代码在跨父视图搬移主克隆前换算全局坐标。

为什么这样做:放大过已栅格化的头像会变模糊。递归恢复倍率可保持克隆清晰。双克隆允许源侧淡出和目标侧淡入重叠。

关键代码摘录

StoryItemSetContainerComponent.swift · L2382–L2407
if let transitionViewImpl = transitionView?.makeView() {
    fixScale(layer: transitionViewImpl.layer)
    transitionViewsImpl.append(transitionViewImpl)
    let transitionSourceContainerView = UIView(frame: self.bounds)
    transitionSourceContainerView.isUserInteractionEnabled = false
    self.componentContainerView.insertSubview(
        transitionSourceContainerView, aboveSubview: self.itemsContainerView
    )
    transitionSourceContainerView.addSubview(transitionViewImpl)
    if let insertCloneTransitionView = transitionView?.insertCloneTransitionView {
        if let transitionCloneViewImpl = transitionView?.makeView() {
            fixScale(layer: transitionCloneViewImpl.layer)
            transitionViewsImpl.append(transitionCloneViewImpl)
            transitionCloneMasterView.isUserInteractionEnabled = false
            let globalFrame = transitionCloneMasterView.convert(
                transitionCloneMasterView.bounds, to: nil
            )
            insertCloneTransitionView(transitionCloneMasterView)
            if let newParentView = transitionCloneMasterView.superview {
                newParentView.frame = newParentView.convert(globalFrame, from: nil)
            }
            self.transitionCloneContainerView.addSubview(transitionCloneViewImpl)
            transitionSourceContainerView.layer.animateAlpha(
                from: 1.0, to: 0.0, duration: 0.16, removeOnCompletion: false
            )
        }
    }
}

代价与风险:递归遍历整个子层树。层树很大时会消耗主线程时间。对动态视频内容只能保留当时快照感。

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

12. Stories 把全部暂停原因收口为播放模式函数

#10-12Telegram-iOSUIKit源码分析文本与输入手势与滚动

做了什么:播放模式由缩放、下拉、菜单、输入、录音、贴纸和隐私页共同决定。上下文菜单或文本选择会返回 .blurred。其余干扰状态通常返回 .pause。

为什么这样做:各 UI 单独暂停视频容易漏恢复或竞争。单个函数把状态优先级显式化。该机制也让按住暂停后的“滑动以定位”界面能复用 isProgressPaused。

关键代码摘录

StoryItemSetContainerComponent.swift · L1430–L1479
private func itemProgressMode() -> StoryContentItem.ProgressMode {
    guard let component = self.component else {
        return .pause
    }
    if component.pinchState != nil {
        return .pause
    }
    if self.verticalPanState != nil {
        return .pause
    }
    if self.contextController != nil {
        return .blurred
    }
    if self.inputPanelExternalState.isEditing || component.isProgressPaused ||
       self.sendMessageContext.actionSheet != nil ||
       self.sendMessageContext.isViewingAttachedStickers ||
       self.sendMessageContext.audioRecorderValue != nil ||
       self.sendMessageContext.videoRecorderValue != nil ||
       self.viewListDisplayState != .hidden {
        return .pause
    }
    if let reactionContextNode = self.reactionContextNode,
       reactionContextNode.isReactionSearchActive {
        return .pause
    }
    if self.privacyController != nil {
        return .pause
    }

代价与风险:条件列表很长。新增覆盖层时必须记得加入。漏加会让故事在模态 UI 后继续播放。

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

13. Stories 明确规定点击让位于平移,并只和滚动平移共存

做了什么:点击识别器会等待平移失败。故事主平移只与滚动视图或当前观看列表的平移同时识别。其他组合被拒绝。

为什么这样做:边缘点按翻页不应抢走下拉关闭或横向拖动。完全禁止同时识别会使滚动列表和故事容器卡顿。白名单限制了手势副作用。

关键代码摘录

StoryItemSetContainerComponent.swift · L910–L937
@objc public func gestureRecognizer(
    _ gestureRecognizer: UIGestureRecognizer,
    shouldRequireFailureOf otherGestureRecognizer: UIGestureRecognizer
) -> Bool {
    if gestureRecognizer is UITapGestureRecognizer {
        if otherGestureRecognizer is UIPanGestureRecognizer {
            return true
        }
        return false
    } else {
        return false
    }
}
public func gestureRecognizer(
    _ gestureRecognizer: UIGestureRecognizer,
    shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
) -> Bool {
    if gestureRecognizer is UIPanGestureRecognizer {
        if let otherGestureRecognizer = otherGestureRecognizer as? UIPanGestureRecognizer {
            if otherGestureRecognizer.view is UIScrollView {
                return true
            }
            if let component = self.component,
               let viewList = self.viewLists[component.slice.item.id],
               let viewListView = viewList.view.view as? StoryItemSetViewListComponent.View,
               otherGestureRecognizer.view === viewListView {
                return true
            }
        }
        return false
    } else {
        return false
    }
}

代价与风险:新加入的可滚动子视图需要补白名单。否则它可能和故事平移冲突。无明显私有 API 风险。

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

14. 头像画廊只预加载当前项前后两张

#10-14Telegram-iOSUIKit源码分析私有 API性能与内存

做了什么:头像轮播窗口固定为当前项前后各两项。代码以资源 ID 复用节点。只有中心项在要求时同步加载。首项还可限制为全尺寸资源。

为什么这样做:头像可能包含视频和高分辨率照片。一次加载全部头像会占用解码内存。窗口预取可在滑到相邻项时减少等待。

关键代码摘录

PeerInfoAvatarListNode.swift · L1649–L1673
if self.currentIndex >= 0 && self.currentIndex < self.items.count {
    let preloadSpan: Int = 2
    for i in max(0, self.currentIndex - preloadSpan) ...
             min(self.currentIndex + preloadSpan, self.items.count - 1) {
        if self.items[i].representations.isEmpty {
            continue
        }
        validIds.append(self.items[i].id)
        var itemNode: PeerInfoAvatarListItemNode?
        var wasAdded = false
        if let current = self.itemNodes[self.items[i].id] {
            itemNode = current
            if update {
                var synchronous = synchronous && i == self.currentIndex
                if case .custom = self.items[i], self.updateCustomItemsOnlySynchronously {
                    synchronous = true
                }
                current.setup(item: self.items[i], isMain: i == 0,
                    synchronous: synchronous && i == self.currentIndex,
                    fullSizeOnly: self.firstFullSizeOnly && i == 0)
            }
        } else if let peer = self.peer {
            wasAdded = true
            let addedItemNode = PeerInfoAvatarListItemNode(context: self.context, peer: peer)
            itemNode = addedItemNode
            addedItemNode.setup(item: self.items[i], isMain: i == 0,
                progress: i == 0 ? self.additionalEntryProgress : nil,
                synchronous: (i == 0 && i == self.currentIndex) ||
                    (synchronous && i == self.currentIndex),
                fullSizeOnly: self.firstFullSizeOnly && i == 0)

代价与风险:快速跨越超过两项时仍可能出现加载占位。缓存失效和 validIds 清理逻辑必须保持一致。无明显私有 API 风险。

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

15. 切前后摄时暂存首帧,并校正录制时间线

#10-15Telegram-iOSUIKit源码分析文本与输入

做了什么:录制器用 videoTimelineOffset 重映射展示时间。切相机后的首帧先被 heldFrame 暂存。解析后,首帧可越过正常的停止边界检查。

为什么这样做:前后摄切换的时间戳可能跳变。直接写入会造成非单调 PTS,或错误截掉第一帧。暂存可等待切换重定时状态完成。

关键代码摘录

VideoRecorder.swift · L277–L317
let frame = PendingVideoFrame(
    source: source,
    rawPresentationTime: rawPresentationTime,
    presentationTime: rawPresentationTime - self.videoTimelineOffset
)
if var retiming = self.singleCameraSwitch,
   retiming.isCommitted,
   !retiming.isResolved,
   retiming.to == position {
    if retiming.heldFrame == nil {
        retiming.heldFrame = frame
    }
    self.singleCameraSwitch = retiming
    return
}
let isFirstSwitchFrame = self.consumeFirstSingleCameraSwitchVideoFrameIfNeeded(frame)
self.submitVideoFrame(frame, allowPastStopBoundary: isFirstSwitchFrame)
}
private func submitVideoFrame(_ frame: PendingVideoFrame,
                              allowPastStopBoundary: Bool = false) {
    if let lastVideoTime = self.lastVideoTime,
       frame.presentationTime <= lastVideoTime {
        return
    }
    if self.stopRequested && self.videoReachedStopTarget &&
       !allowPastStopBoundary {
        return
    }

代价与风险:切换解析失败时可能丢失一帧。状态机较难维护。录制时间线的完整修正路径未在本片段验证。

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

本页目录