媒体、Stories 与个人页
粒子行为、零拷贝视频纹理与个人页下拉画廊。
本主题范围:Telegram-iOS 的 Stories、相机、媒体编辑、DrawingUI、个人页头像、GalleryUI、Gifts 与 Premium 的富媒体交互和渲染实现。
结论:此范围最激进的做法有两类。第一类直接触及私有 Core Animation 或 UIKit 实现。第二类把预览、滤镜和 3D 动画做成可控的 GPU 管线。未在本次已读代码中确认 Stories 的“立方体翻页”实现。因此本文不把它列为结论。
1. 用运行时调用私有 CAEmitterBehavior
- Telegram-iOS:…/Display/Source/UIKitUtils.swift#L935–L943
- Telegram-iOS:…/GiftViewScreen/Sources/GiftAuctionBidScreen.swift#L3263–L3284
位置说明: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 只能配置基础粒子属性。该私有行为可使颜色随生命周期变化。该私有行为也可让粒子被吸引器牵引。
关键代码摘录
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 拒绝风险。系统实现变化会导致强制解包崩溃。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 2 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
2. 个人页用私有 _verticalVelocity 把第二次下拉变成头像画廊
做了什么:代码把 "_" 和 "verticalVelocity" 拼成 _verticalVelocity。它通过 KVC 读取 UIScrollView 的私有纵向速度。首次下拉到 -32 时展开头像。展开后再次下拉会触发画廊。
为什么这样做:UIScrollViewDelegate 不公开当前滚动速度。panGestureRecognizer.velocity(in:) 不能完整替代内部滚动减速速度。该值让头像展开和后续手势更连续。
关键代码摘录
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 抛异常或返回空值。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 报告摘要
- 怎么做到:拼接
@"_"+"verticalVelocity"并valueForKey:读取。 - 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
3. 圆视频把 NV12 的两个平面零拷贝映射到 Metal
做了什么:相机输入的 NV12 数据不经过 CGImage。Y 平面映射为 .r8Unorm。CbCr 平面映射为 .rg8Unorm。计算着色器随后完成颜色转换、镜像和裁切。
为什么这样做:CPU 转图会增加内存带宽和延迟。双平面纹理让 CVPixelBuffer 直接成为 GPU 输入。这适合实时圆视频预览和录制。
关键代码摘录
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。纹理缓存失败时会丢弃当前帧。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 报告摘要
- 怎么做到:为每平面创建
CVMetalTexture,失败即丢帧。 - 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
4. 圆视频先低分辨率模糊,再在同一命令缓冲区合成
做了什么:代码以 30 fps 离散选择装饰图集帧。代码先写入小尺寸纹理。代码对小纹理执行模糊。后续合成器读取模糊纹理、原始 YUV、图集和水印。
为什么这样做:直接模糊全尺寸相机帧成本很高。先降采样会显著减少卷积工作量。同一 MTLCommandBuffer 也保留了 GPU 操作顺序。
关键代码摘录
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。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 报告摘要
- 怎么做到:
downsample→blur.encode→合成 pass。 - 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
5. 圆视频用有上限的 CVPixelBufferPool 施加背压
做了什么:输出池最少保留四个缓冲。代码主动申请四个缓冲后立即释放引用。池的分配阈值为六。isRendering 还保证同一时刻只处理一帧。
为什么这样做:预热可避免首帧和切相机时临时分配。阈值会阻止编码或 GPU 落后时无限堆积帧。主动丢帧通常优于实时相机的内存膨胀。
关键代码摘录
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 必须在完成回调中正确复位。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 报告摘要
- 怎么做到:
kCVPixelBufferPoolAllocationThresholdKey:6创建像素缓冲。 - 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
6. 处理完成前显式保活像素缓冲和纹理
做了什么:代码保留输入、输出 CVPixelBuffer 和 CVMetalTexture。它直到 GPU 命令完成后才结束这些对象的生命周期。回调也切回相机的 mediaQueue。
为什么这样做:Swift 的局部变量可在 GPU 完成前释放。仅提交命令缓冲不保证 Core Video 对象仍存活。这里避免了偶发黑帧、访问失效纹理和竞态。
关键代码摘录
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 风险。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 报告摘要
- 怎么做到:捕获
CVPixelBuffer/CVMetalTexture,回主媒体队列复位状态。 - 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
7. 媒体编辑器用分块直方图生成局部增强 LUT
做了什么:代码为每个网格块计算 MPSImageHistogram。代码将每块直方图写进共享私有缓冲。计算着色器再生成一组 LUT。
为什么这样做:全局自动增强会让局部阴影或高光失真。分块直方图相当于局部对比度增强。GPU 直方图和 LUT 也避免了在调节滑块时回读像素。
关键代码摘录
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(¶meters,
length: MemoryLayout<MediaEditorEnhanceLUTGeneratorParameters>.size, index: 1)代价与风险:小网格会显得过度处理。每帧创建直方图缓冲仍有成本。该代码需要 Metal Performance Shaders。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
8. 增强滤镜复用私有输出纹理,并跳过无用清屏
做了什么:滤镜只在首次执行时创建输出纹理。纹理使用 .private 存储。渲染通道使用 .dontCare 作为加载动作。
为什么这样做:编辑预览会重复渲染同尺寸媒体。反复分配纹理会抖动内存和帧时间。片元着色器会覆盖每个像素,所以清屏和加载旧内容没有价值。
关键代码摘录
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代价与风险:若输入尺寸或格式在对象生命周期内改变,缓存可能不匹配。该片段本身未展示重建逻辑。此假设未验证。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
9. 切换嵌入式相机时冻结旧预览并叠加圆形毛玻璃
做了什么:切相机前先取 snapshotView。代码把快照留在原位。代码再以翻转动画显示圆形深色模糊层。切换完成后分别淡出毛玻璃和快照。
为什么这样做:AVCaptureSession 切前后摄常有黑帧或曝光跳变。静态快照使内容连续。模糊层还掩盖了快照不能反映新相机的事实。
关键代码摘录
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 合成成本。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
10. GalleryUI 用双过渡视图同步缩略图与全屏圆角
做了什么:画廊创建两个过渡视图。一个加入上层 transition surface。一个留在项目自身。回调收到源尺寸、目标尺寸、屏幕圆角和 progress。
为什么这样做:单个视图无法同时正确服务跨父视图坐标系和项目内部遮挡。双视图避免转场期间出现空洞。显式传递硬件屏幕圆角可让全屏端贴合设备。
关键代码摘录
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 闭包的提供方完成。该提供方不在本次已读片段中。因此插值公式未验证。两个临时视图会增加转场复杂度。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
11. Stories 退场时把克隆层递归设回屏幕光栅倍率
做了什么:fixScale 会将层及所有子层的 rasterizationScale 设为 UIScreenScale。退场时故事内容和目标头像各有一份过渡克隆。代码在跨父视图搬移主克隆前换算全局坐标。
为什么这样做:放大过已栅格化的头像会变模糊。递归恢复倍率可保持克隆清晰。双克隆允许源侧淡出和目标侧淡入重叠。
关键代码摘录
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
)
}
}
}代价与风险:递归遍历整个子层树。层树很大时会消耗主线程时间。对动态视频内容只能保留当时快照感。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
12. Stories 把全部暂停原因收口为播放模式函数
做了什么:播放模式由缩放、下拉、菜单、输入、录音、贴纸和隐私页共同决定。上下文菜单或文本选择会返回 .blurred。其余干扰状态通常返回 .pause。
为什么这样做:各 UI 单独暂停视频容易漏恢复或竞争。单个函数把状态优先级显式化。该机制也让按住暂停后的“滑动以定位”界面能复用 isProgressPaused。
关键代码摘录
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 后继续播放。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
13. Stories 明确规定点击让位于平移,并只和滚动平移共存
做了什么:点击识别器会等待平移失败。故事主平移只与滚动视图或当前观看列表的平移同时识别。其他组合被拒绝。
为什么这样做:边缘点按翻页不应抢走下拉关闭或横向拖动。完全禁止同时识别会使滚动列表和故事容器卡顿。白名单限制了手势副作用。
关键代码摘录
@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 风险。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
14. 头像画廊只预加载当前项前后两张
做了什么:头像轮播窗口固定为当前项前后各两项。代码以资源 ID 复用节点。只有中心项在要求时同步加载。首项还可限制为全尺寸资源。
为什么这样做:头像可能包含视频和高分辨率照片。一次加载全部头像会占用解码内存。窗口预取可在滑到相邻项时减少等待。
关键代码摘录
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 风险。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
15. 切前后摄时暂存首帧,并校正录制时间线
做了什么:录制器用 videoTimelineOffset 重映射展示时间。切相机后的首帧先被 heldFrame 暂存。解析后,首帧可越过正常的停止边界检查。
为什么这样做:前后摄切换的时间戳可能跳变。直接写入会造成非单调 PTS,或错误截掉第一帧。暂存可等待切换重定时状态完成。
关键代码摘录
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
}代价与风险:切换解析失败时可能丢失一帧。状态机较难维护。录制时间线的完整修正路径未在本片段验证。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。