Telegram 技巧档案UIKIT · APPKIT
iOS · UIKit

布局与动画

私有圆角、劫持系统弹簧、additive 动画与模型先到位。

范围:Telegram-iOS 的 Display 布局过渡、Core Animation 工具、刷新率控制与 ComponentFlow UIKit 过渡实现。

结论:该体系把最终状态先写入模型层。它再用显式动画追赶屏幕状态。它还用私有接口补齐系统曲线、圆角和刷新率能力。ComponentFlow 通过稳定身份、视图附着状态和延迟销毁,实现接近 SwiftUI 的 UIKit diff 过渡。

1. 用私有 cornerRadii 实现零掩膜的四角圆角

#03-01Telegram-iOSUIKit源码分析私有 API

做了什么:它探测私有 setter setCornerRadii:。它通过 KVC 读写私有 cornerRadii。CornerRadii.boxedValue 按 CACornerRadii 的四个 CGSize 布局封箱。调用方还能从展示层读取中断时的实际圆角。

为什么这样做:cornerRadius 不能表达四个独立圆角。CAShapeLayer 掩膜会增加离屏渲染。这个属性让圆角作为图层可动画属性参与合成。

关键代码摘录

CALayerCornerRadii.swift · L95–L119
    static let cornerRadiiSupported: Bool = {
        return CALayer.instancesRespond(to: NSSelectorFromString("setCornerRadii:"))
    }()

    /// The layer's current four radii, read back through KVC — so an in-flight animation can be
    /// resumed from `presentation()?.cornerRadii` exactly like any ordinary layer property, rather
    /// than from a shadow copy the caller has to keep in sync.
    ///
    /// Not named to shadow anything: Swift cannot see the private ObjC property (it is in no header),
    /// so this is the only `cornerRadii` in scope and `value(forKey:)` reaches the real one.
    var cornerRadii: CornerRadii? {
        guard CALayer.cornerRadiiSupported, let value = self.value(forKey: cornerRadiiKey) as? NSValue else {
            return nil
        }
        return CornerRadii(boxedValue: value)
    }

    /// Applies four radii without animation. No-op when unsupported — callers that need a guaranteed
    /// clip must check `CALayer.cornerRadiiSupported` and fall back to a mask.
    func setCornerRadii(_ radii: CornerRadii) {
        guard CALayer.cornerRadiiSupported else {
            return
        }
        self.setValue(radii.boxedValue, forKey: cornerRadiiKey)
    }

代价与风险:这是私有 API。它有审核和系统兼容风险。源码会在 selector 不存在时降级为 no-op。需要由调用方提供掩膜后备。

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

2. 劫持 UIKit 事务内的弹簧动画

做了什么:ComponentTransition.animateView 先把覆盖参数压栈。它调用 UIView.animate。+load 再交换 -[CALayer addAnimation:forKey:]。交换后的实现会复制 UIKit 生成的动画,并替换为指定刚度、阻尼或三次贝塞尔曲线。

为什么这样做:UIKit block 动画能处理 Auto Layout 和属性批量更新。它却不公开每个属性的精确 spring 参数和任意曲线。该做法保留 UIKit 事务,同时改写提交到 Core Animation 的动画。

关键代码摘录

UIViewController+Navigation.m · L191–L215 等 2 处
- (void)_65087dc8_addAnimation:(CAAnimation *)anim forKey:(NSString *)key {
    CAAnimation *updatedAnimation = anim;
    if (currentSpringParametersOverrideStack().count != 0 && [anim isKindOfClass:[CASpringAnimation class]]) {
        CALayerSpringParametersOverride *overrideData = [currentSpringParametersOverrideStack() lastObject];
        if (overrideData) {
            if ([overrideData.parameters isKindOfClass:[CALayerSpringParametersOverrideParametersSpring class]]) {
                CALayerSpringParametersOverrideParametersSpring *parameters = (CALayerSpringParametersOverrideParametersSpring *)overrideData.parameters;
                CABasicAnimation *sourceAnimation = (CABasicAnimation *)anim;

                CASpringAnimation *animation = makeSpringBounceAnimationImpl(sourceAnimation.keyPath, 0.0, parameters.damping);

                animation.stiffness = parameters.stiffness;
                animation.fromValue = sourceAnimation.fromValue;
                animation.toValue = sourceAnimation.toValue;
                animation.byValue = sourceAnimation.byValue;
                animation.additive = sourceAnimation.additive;
                animation.removedOnCompletion = sourceAnimation.isRemovedOnCompletion;
                animation.fillMode = sourceAnimation.fillMode;
                animation.beginTime = sourceAnimation.beginTime;
                animation.timeOffset = sourceAnimation.timeOffset;
                animation.repeatCount = sourceAnimation.repeatCount;
                animation.autoreverses = sourceAnimation.autoreverses;

                float k = animationDurationFactorImpl();
                __unused float speed = 1.0f;

代价与风险:这是全局 method swizzling。栈是进程全局 NSMutableArray。非主线程并发提交会有状态串扰风险。_65087dc8_addAnimation:forKey: 不是隐藏私有名。它是交换别名。源码在 L570 明确把它与公开 addAnimation:forKey: 交换。

证据链 · #03-02iOS 未做二进制验证
源码分析
已在 Telegram-iOS 固定提交中逐行确认,共 2 处源码位置。
报告摘要
怎么做到:压栈参数→method_exchangeImplementations→拷贝并替换动画。
二进制验证
未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。

3. 调用私有 _solveForInput: 统一手工插值和系统弹簧

做了什么:ListViewAnimation 不自行近似系统弹簧。运行时工具将字符串 "_" + "solveForInput:" 还原为 _solveForInput:。它读取方法签名,并按 float 或 double 调用 IMP。找不到时它回退为线性 t。

为什么这样做:列表的手工逐帧插值需要同 Core Animation 弹簧一致。直接手写阻尼方程容易同系统实现偏离。该办法可复用系统实际求解器。

关键代码摘录

ListViewAnimation.swift · L91–L110 等 2 处
private let springAnimationIn: CABasicAnimation = {
    let animation = makeSpringAnimation("", duration: 0.5)
    return animation
}()

let springAnimationSolver: (CGFloat) -> CGFloat = { () -> (CGFloat) -> CGFloat in
    if #available(iOS 9.0, *) {
        return { t in
            return springAnimationValueAt(springAnimationIn, t)
        }
    } else {
        return { t in
            return bezierPoint(0.23, 1.0, 0.32, 1.0, t)
        }
    }
}()

public let listViewAnimationCurveSystem: (CGFloat) -> CGFloat = { t in
    return springAnimationSolver(t)
}

代价与风险:_solveForInput: 是私有 selector。它有审核和未来签名变更风险。源码以运行时查找和参数编码检查降低崩溃风险。回退曲线是否符合旧系统体验未验证。

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

4. 借用未公开的 UIKit 曲线值 7 << 16

做了什么:代码把 UIView.AnimationOptions 的曲线字段设为 7。它把该值命名为 .spring。ContainedViewLayoutTransition 对 .spring 和 .customSpring 使用相同 raw value。

为什么这样做:公开曲线只有线性和 ease 系列。该值让 UIKit block 动画走系统内部的 spring 路径。它可同显式 Core Animation spring 保持较一致的观感。

关键代码摘录

Transition.swift · L81–L99 等 2 处
    var viewAnimationOptions: UIView.AnimationOptions {
        switch self {
        case .linear:
            return [.curveLinear]
        case .easeInOut:
            return [.curveEaseInOut]
        case .easeIn:
            return [.curveEaseIn]
        case .spring:
            return UIView.AnimationOptions(rawValue: 7 << 16)
        case .custom:
            return []
        case .bounce:
            return []
        }
    }
}

public extension ComponentTransition.Animation {

代价与风险:7 不在公开曲线枚举中。它属于依赖实现细节的技巧。代码没有说明该值当前是否仍是键盘曲线。这个对应关系未验证。

证据链 · #03-04iOS 未做二进制验证
源码分析
已在 Telegram-iOS 固定提交中逐行确认,共 2 处源码位置。
报告摘要
怎么做到:自定义枚举分支映射该 raw value,统一过渡实现。
二进制验证
未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。

5. 通过私有 _scrollHeartbeat 强制旧系统滚动到高刷新率

#03-05Telegram-iOSUIKit报告精选 03-3源码分析私有 API手势与滚动

做了什么:它将字符串拼接为 _scrollHeartbeat。它用 KVC 取出 UIScrollView 内部的 CADisplayLink。它在 iOS 15 的高刷设备上,把该 link 的最小、最大和首选 FPS 都设为屏幕最大值。

为什么这样做:自己的 display link 高刷不足以提升系统滚动的采样频率。这个修复直接控制滚动内部 heartbeat。它在 iOS 16 及以上停用,表明作者认为系统问题已变更或已修复。

关键代码摘录

UIViewController+Navigation.m · L293–L317
- (void)fixScrollDisplayLink {
    if (@available(iOS 16.0, *)) {
        return;
    }
    static NSString *scrollHeartbeatKey = nil;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        scrollHeartbeatKey = [NSString stringWithFormat:@"_%@", @"scrollHeartbeat"];
    });

    id value = [self valueForKey:scrollHeartbeatKey];
    if ([value isKindOfClass:[CADisplayLink class]]) {
        CADisplayLink *displayLink = (CADisplayLink *)value;
        if ([displayLink associatedObjectForKey:forceFullRefreshRateKey] == nil) {
            [displayLink setAssociatedObject:@true forKey:forceFullRefreshRateKey];

            if (@available(iOS 15.0, *)) {
                float maxFps = [UIScreen mainScreen].maximumFramesPerSecond;
                if (maxFps > 61.0f) {
                    [displayLink setPreferredFrameRateRange:CAFrameRateRangeMake(maxFps, maxFps, maxFps)];
                }
            }
        }
    }
}

代价与风险:_scrollHeartbeat 是私有 KVC key。真实名字由 @"_%@", @"scrollHeartbeat" 直接确认。它有审核、KVC 异常和耗电风险。

证据链 · #03-05iOS 未做二进制验证
源码分析
已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
报告摘要
怎么做到:valueForKey:@"_scrollHeartbeat",标记后设置 preferredFrameRateRange。
二进制验证
未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。

6. 为 iOS 26 弹簧写入私有高刷原因码

#03-06Telegram-iOSUIKit源码分析私有 API

做了什么:它为 iOS 26 分支换用一组不同弹簧参数。它禁止 overdamping。它通过 KVC 写入 highFrameRateReason = 1048619。它再请求 80–120 FPS,并首选 120 FPS。

为什么这样做:代码把 iOS 26 视为系统动画参数改变的分界点。高帧率范围只表达请求。私有原因码看起来用于让系统接受高刷理由。原因码的具体语义未验证。

关键代码摘录

UIKitUtils.m · L68–L84
CABasicAnimation * _Nonnull make26SpringAnimationImpl(NSString * _Nonnull keyPath, double duration) {
    CASpringAnimation *springAnimation = [CASpringAnimation animationWithKeyPath:keyPath];
    springAnimation.mass = 1.0f;
    springAnimation.stiffness = 555.027;
    springAnimation.damping = 47.118;
    springAnimation.duration = duration;
    springAnimation.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionLinear];
    if (@available(iOS 17.0, *)) {
        springAnimation.allowsOverdamping = false;
    }
    if (@available(iOS 15.0, *)) {
        [springAnimation setValue:@(1048619) forKey:@"highFrameRateReason"];
        springAnimation.preferredFrameRateRange = CAFrameRateRangeMake(80.0, 120.0, 120.0);
    }

    return springAnimation;
}

代价与风险:highFrameRateReason 是未公开 KVC 属性。魔数没有源码解释。它有审核和系统兼容风险。实际是否得到 120 FPS 未验证。

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

7. 用存储几何量规避 CALayer.frame 的浮点重启动画

做了什么:它不直接比较派生的 frame。在无变换时,它从目标 frame 推出 position,并比较图层存储的 position 和 bounds。有变换时才退回比较 frame。

为什么这样做:frame 由 position、bounds 和 anchorPoint 推导。分数像素往返会有舍入误差。假变化会反复触发布局动画,并造成气泡抖动。

关键代码摘录

ContainedViewLayoutTransition.swift · L30–L42
func layerIsAlreadyAtFrame(_ layer: CALayer, _ frame: CGRect) -> Bool {
    guard CATransform3DIsIdentity(layer.transform) else {
        return layer.frame.equalTo(frame)
    }
    let anchorPoint = layer.anchorPoint
    let expectedPosition = CGPoint(
        x: frame.minX + frame.width * anchorPoint.x,
        y: frame.minY + frame.height * anchorPoint.y
    )
    // The setter keeps the existing bounds origin and replaces only the size.
    let expectedBounds = CGRect(origin: layer.bounds.origin, size: frame.size)
    return layer.position == expectedPosition && layer.bounds == expectedBounds
}

代价与风险:这段逻辑依赖 frame setter 的存储行为。非 identity transform 不能使用该推导。源码已为该情况回退。无明显审核风险。

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

8. 从展示层接力中断中的布局动画

做了什么:新布局先写入节点模型层。若旧动画仍在运行,它从展示层的当前 frame 取起点。随后显式动画从屏幕当前位置过渡到新模型值。

为什么这样做:直接从旧模型值开动画会产生跳变。只修改展示层又会让布局状态失真。模型层先更新可让后续布局立即看到最终值。

关键代码摘录

ContainedViewLayoutTransition.swift · L234–L248
            case let .animated(duration, curve):
                let previousFrame: CGRect
                if beginWithCurrentState, (node.layer.animation(forKey: "position") != nil || node.layer.animation(forKey: "bounds") != nil), let presentation = node.layer.presentation() {
                    previousFrame = presentation.frame
                } else {
                    previousFrame = node.frame
                }
                node.frame = frame
                node.layer.animateFrame(from: previousFrame, to: frame, duration: duration, delay: delay, timingFunction: curve.timingFunction, mediaTimingFunction: curve.mediaTimingFunction, force: force, completion: { result in
                    if let completion = completion {
                        completion(result)
                    }
                })
            }
        }

代价与风险:presentation() 可能为空。代码已回退到模型层。每个属性都必须使用一致的动画 key。否则无法判断是否应接力。

证据链 · #03-08iOS 未做二进制验证
源码分析
已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
报告摘要
怎么做到:检测 position/bounds 动画存在→读取展示层→animateFrame。
二进制验证
未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。

9. 将 frame 拆成两个动画,并汇合完成信号

#03-09Telegram-iOSUIKit报告精选 03-5源码分析私有 API

做了什么:animateFrame 用 position 和 bounds 两个 CA 动画表示一个 frame。它分别记录完成状态。任一动画被打断时,它将整个 frame 动画报告为失败。两个成功后,它才报告成功。

为什么这样做:frame 不是 Core Animation 的独立可动画存储属性。两个回调若直接透传,会让上层过早删除视图或重复收尾。

关键代码摘录

CAAnimationUtils.swift · L510–L535
        var interrupted = false
        var completedPosition = false
        var completedBounds = false
        let partialCompletion: () -> Void = {
            if interrupted || (completedPosition && completedBounds) {
                if let completion = completion {
                    completion(!interrupted)
                }
            }
        }

        var fromPosition = CGPoint(x: from.midX, y: from.midY)
        var toPosition = CGPoint(x: to.midX, y: to.midY)
        var fromBounds = CGRect(origin: self.bounds.origin, size: from.size)
        var toBounds = CGRect(origin: self.bounds.origin, size: to.size)

        if additive {
            fromPosition.x = -(toPosition.x - fromPosition.x)
            fromPosition.y = -(toPosition.y - fromPosition.y)
            toPosition = CGPoint()

            fromBounds.size.width = -(toBounds.width - fromBounds.width)
            fromBounds.size.height = -(toBounds.height - fromBounds.height)
            toBounds = CGRect()
        }

代价与风险:该协调依赖两个动画均触发 delegate。复杂的外部动画覆盖可能使结果难以推理。无明显私有 API 风险。

证据链 · #03-09iOS 未做二进制验证
源码分析
已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
报告摘要
怎么做到:跟踪两个完成标志与中断状态,协调总回调。
二进制验证
未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。

10. 用 additive 位移追赶已更新的模型 frame

做了什么:它先设置目标 frame。它再以 additive position 动画,从旧坐标减新坐标的 offset 动到零。视觉上图层从旧位置移动到新位置。

为什么这样做:模型层始终保持最终布局。额外位移不会改变最终位置。多个位移动画也可叠加,不必反复改写同一个 model position。

关键代码摘录

ContainedViewLayoutTransition.swift · L320–L336
    func updateFrameAdditive(layer: CALayer, frame: CGRect, force: Bool = false, completion: ((Bool) -> Void)? = nil) {
        if layer.frame.equalTo(frame) && !force {
            completion?(true)
        } else {
            switch self {
            case .immediate:
                layer.frame = frame
                if let completion = completion {
                    completion(true)
                }
            case .animated:
                let previousFrame = layer.frame
                layer.frame = frame
                self.animatePositionAdditive(layer: layer, offset: CGPoint(x: previousFrame.minX - frame.minX, y: previousFrame.minY - frame.minY))
            }
        }
    }

代价与风险:此分支没有把 completion 传给 animatePositionAdditive。调用方不能依赖动画分支的回调。该行为是否是有意设计未验证。

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

11. 分别以不同曲线过渡水平和垂直几何量

#03-11Telegram-iOSUIKit报告精选 03-6源码分析私有 API

做了什么:CombinedTransition 保有两个独立 ContainedViewLayoutTransition。它把 frame 拆成 x、y、宽和高四个 additive 动画。它等待四个回调后再完成。

为什么这样做:键盘避让或侧滑常需水平和垂直采用不同持续时间或曲线。一个整体 frame 动画无法表达这种组合。

关键代码摘录

ContainedViewLayoutTransition.swift · L1975–L1999
        var remainingKeys = Keys.allCases
        var completedValue = true
        let completeKey: (Keys, Bool) -> Void = { key, completed in
            remainingKeys.removeAll(where: { $0 == key })
            if !completed {
                completedValue = false
            }
            if remainingKeys.isEmpty {
                completion?(completedValue)
            }
        }

        self.horizontal.animatePositionAdditive(layer: layer, offset: CGPoint(x: fromFrame.midX - toFrame.midX, y: 0.0), completion: { result in
            completeKey(.positionX, result)
        })
        self.vertical.animatePositionAdditive(layer: layer, offset: CGPoint(x: 0.0, y: fromFrame.midY - toFrame.midY), completion: { result in
            completeKey(.positionY, result)
        })

        self.horizontal.animateWidthAdditive(layer: layer, value: fromFrame.width - toFrame.width, completion: { result in
            completeKey(.sizeWidth, result)
        })
        self.vertical.animateHeightAdditive(layer: layer, value: fromFrame.height - toFrame.height, completion: { result in
            completeKey(.sizeHeight, result)
        })

代价与风险:四个 key path 同时运行会增加动画数量。完成回调受最慢轴约束。无明显私有 API 风险。

证据链 · #03-11iOS 未做二进制验证
源码分析
已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
报告摘要
怎么做到:两个 ContainedViewLayoutTransition 并行,汇合四个完成。
二进制验证
未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。

12. 在 action(forKey:) 中同时禁用隐式动画和追踪挂载

#03-12Telegram-iOSUIKit源码分析Core Animation

做了什么:自定义 CAAction 的 run 为空。图层对任何 action key 都返回该对象。它在 kCAOnOrderIn 和 kCAOnOrderOut 时维护层级状态并触发回调。

为什么这样做:返回空 action 比依赖每次 CATransaction.setDisableActions 更集中。它还把 Core Animation 的进出层级事件变成生命周期钩子。HierarchyTrackingLayer 在独立模块复制了同一机制。

关键代码摘录

SimpleLayer.swift · L10–L24
open class SimpleLayer: CALayer {
    public var didEnterHierarchy: (() -> Void)?
    public var didExitHierarchy: (() -> Void)?
    public private(set) var isInHierarchy: Bool = false

    override open func action(forKey event: String) -> CAAction? {
        if event == kCAOnOrderIn {
            self.isInHierarchy = true
            self.didEnterHierarchy?()
        } else if event == kCAOnOrderOut {
            self.isInHierarchy = false
            self.didExitHierarchy?()
        }
        return nullAction
    }

代价与风险:它会压制该层全部隐式动画。子类需要显式添加动画。kCAOnOrderIn 的复杂容器时序需实际界面验证。

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

13. 用一个共享 DisplayLink 服务多种帧率需求

做了什么:单例仅保有一个 CADisplayLink。它从活动 request 中取最高 FPS。高刷设备仅在需求超过 60 FPS 时请求 120 FPS。iPad 无条件请求该范围。每个低 FPS request 再通过累计 targetTimestamp - timestamp 节流。

为什么这样做:为每个动画创建 display link 会带来重复回调。全局 link 可让 30、60 和 120 FPS 客户端共享 v-sync。前后台切换时它会失效 link,避免后台耗电。

关键代码摘录

DisplayLinkAnimator.swift · L89–L94 等 2 处
            if #available(iOS 15.0, *) {
                let maxFps = Float(UIScreen.main.maximumFramesPerSecond)
                if maxFps > 61.0 {
                    var frameRateRange: CAFrameRateRange
                    switch maxFramesPerSecond {
                    case let .fps(fps):
                        if fps > 60 {
                            frameRateRange = CAFrameRateRange(minimum: 30.0, maximum: 120.0, preferred: 120.0)
                        } else {
                            frameRateRange = .default
                        }
                    case .max:
                        frameRateRange = CAFrameRateRange(minimum: 30.0, maximum: 120.0, preferred: 120.0)
                    }

                    if isIpad {
                        frameRateRange = CAFrameRateRange(minimum: 30.0, maximum: 120.0, preferred: 120.0)
                    }

                    if displayLink.preferredFrameRateRange != frameRateRange {
                        displayLink.preferredFrameRateRange = frameRateRange
                        print("SharedDisplayLinkDriver: switch to \(frameRateRange)")
                    }
                }
            }

代价与风险:iPad 强制 120 FPS 可能增加功耗。0.95 的节流阈值会产生轻微时间误差。无明显私有 API 风险。

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

14. 用稳定身份移动 UIKit 子视图,而不是重建

做了什么:ChildMap 将 map 对象地址和业务 key 组合为 child ID。更新时它按 ID 找既有 view。顺序变动时,它调整索引并用 insertSubview(_:at:) 重排,而不重新创建 view。

为什么这样做:组件值每次都可重建。UIKit view 和手势状态却必须保留。稳定身份既保存状态,也允许列表重排过渡。

关键代码摘录

CombinedComponent.swift · L686–L710
                    if let previousView = context.childViews[updatedChild.id] {
                        precondition(updatedChild.view === previousView.view)

                        if index != previousView.index {
                            assert(index < previousView.index)
                            for i in index ..< previousView.index {
                                if let moveView = context.childViews[context.childViewIndices[i]] {
                                    moveView.index += 1
                                }
                            }
                            context.childViewIndices.remove(at: previousView.index)
                            context.childViewIndices.insert(updatedChild.id, at: index)
                            previousView.index = index
                            if let optionalContainer {
                                optionalContainer.addSubview(previousView.view)
                            } else {
                                view.insertSubview(previousView.view, at: index)
                            }
                        }

                        previousView.updateGestures(updatedChild.gestures)
                        previousView.transition = updatedChild.transitionDisappear
                        previousView.transitionWithGuide = updatedChild.transitionDisappearWithGuide

                        (updatedChild.transitionUpdate ?? ComponentTransition.Update.default)(component: updatedChild, view: updatedChild.view, transition: transition)

代价与风险:代码假定移动目标只会向前。它用 assert(index < previousView.index) 限制这个不变量。错误或重复 key 会触发 preconditionFailure。

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

15. 让移除中的子视图持续追踪运动 guide

做了什么:子组件移除后,框架暂不 removeFromSuperview()。它把 view 放进 disappearingChildViews。带 guide 的消失动画可在后续 body 更新中收到新的 guide 位置。它同时关闭用户交互。

为什么这样做:常规 diff 会立即销毁被删元素。这里可实现元素飞向一个仍在移动的目标。动画结束后闭包才移除 view 并清理记录。

关键代码摘录

CombinedComponent.swift · L818–L836
                    if let transitionWithGuide = childView.transitionWithGuide {
                        guard let guide = context.updateContext.configuredGuides[transitionWithGuide.1] else {
                            preconditionFailure("Guide should be configured before using")
                        }
                        context.disappearingChildViews.append(_AnyCombinedComponentContext.DisappearingChildView(
                            view: view,
                            guideId: transitionWithGuide.1,
                            transition: nil,
                            transitionWithGuide: transitionWithGuide.0,
                            completion: completion
                        ))
                        view.isUserInteractionEnabled = false
                        transitionWithGuide.0(
                            stage: .begin,
                            view: view,
                            guide: guide.position,
                            transition: transition,
                            completion: completion
                        )
                    }

代价与风险:guide 必须先配置。否则框架直接失败。动画不结束会暂时保留 view。需要确保每个消失 transition 调用 completion。

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

16. 用等价性、环境和尺寸组成渲染跳过条件

#03-16Telegram-iOSUIKit源码分析性能与内存

做了什么:宿主缓存组件值、容器尺寸和结果尺寸。环境未变且值相等时,它直接返回缓存 size。局部状态更新则绕过相等性检查,强制重跑 update。

为什么这样做:这相当于 UIKit 版声明式 UI 的 invalidation gate。它避免无变化时重复布局和动画。状态回调又不会被值相等错误吞掉。

关键代码摘录

ComponentHostView.swift · L76–L97
        if !forceUpdate, !isEnvironmentUpdated, let currentComponent = self.currentComponent, let currentContainerSize = self.currentContainerSize, let currentSize = self.currentSize {
            if currentContainerSize == containerSize && currentComponent == component {
                self.isUpdating = false
                return currentSize
            }
        }
        self.currentComponent = component
        self.currentContainerSize = containerSize

        componentState._updated = { [weak self] transition, _ in
            guard let strongSelf = self else {
                return
            }
            let _ = strongSelf._update(transition: transition, component: component, maybeEnvironment: {
                preconditionFailure()
            } as () -> Environment<EnvironmentType>, updateEnvironment: false, forceUpdate: true, containerSize: containerSize)
        }

        let updatedSize = component._update(view: componentView, availableSize: containerSize, environment: context.erasedEnvironment, transition: transition)
        if transition.userData(ComponentHostViewSkipSettingFrame.self) == nil {
            transition.setFrame(view: componentView, frame: CGRect(origin: CGPoint(), size: updatedSize))
        }

代价与风险:Component 的 Equatable 必须完整描述视图输入。遗漏字段会导致陈旧 UI。递归更新由 precondition(!isUpdating) 防止。

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

17. 将组件上下文附着到 UIView 来保留类型化状态

做了什么:类型擦除组件把 ComponentContext 作为关联对象附着到它的 UIView。上下文保存组件、环境、布局结果和 ComponentState。状态对象通过 _updated 回传 transition。

为什么这样做:组件本身是瞬时值。view 身份稳定。把状态放在 view 上可跨组件值更新保留类型化 state,而无需全局 store。

关键代码摘录

Component.swift · L80–L101
    func context(typeErasedComponent component: _TypeErasedComponent) -> _TypeErasedComponentContext{
        if let context = objc_getAssociatedObject(self, &UIView_TypeErasedComponentContextKey) as? _TypeErasedComponentContext {
            return context
        } else {
            let context = component._makeContext()
            objc_setAssociatedObject(self, &UIView_TypeErasedComponentContextKey, context, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
            return context
        }
    }
}

open class ComponentState {
    open var _updated: ((ComponentTransition, Bool) -> Void)?
    var isUpdated: Bool = false

    public init() {
    }

    public final func updated(transition: ComponentTransition = .immediate, isLocal: Bool = false) {
        self.isUpdated = true
        self._updated?(transition, isLocal)
    }
}

代价与风险:强制类型转换依赖同一 view 始终匹配组件类型。换错 view 类型会崩溃。关联对象增加隐式生命周期耦合。

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

18. 将任意连续变换离散成按设备帧率采样的关键帧

#03-18Telegram-iOSUIKit源码分析性能与内存

做了什么:setTransformAsKeyframes 接受生成任意 CATransform3D 的闭包。它按曲线求进度,并预先生成 transform 数组。它把最终 transform 写入模型层,再提交关键帧动画。

为什么这样做:CABasicAnimation 只能插值两个 transform。非线性、分段或自定义矩阵变换需要显式采样。关键帧还能复用统一的 ComponentTransition 曲线。

关键代码摘录

Transition.swift · L752–L774
        case let .curve(duration, curve):
            let framesPerSecond: CGFloat
            if #available(iOS 15.0, *) {
                framesPerSecond = duration * CGFloat(UIScreen.main.maximumFramesPerSecond)
            } else {
                framesPerSecond = 60.0
            }

            let numValues = Int(framesPerSecond * duration)
            if numValues == 0 {
                layer.transform = transform(1.0, true)
                completion?(true)
                return
            }

            var values: [AnyObject] = []

            for i in 0 ... numValues {
                let t = curve.solve(at: CGFloat(i) / CGFloat(numValues))
                values.append(NSValue(caTransform3D: transform(t, false)))
            }

            layer.transform = transform(1.0, true)

代价与风险:采样值会占用内存和提交时间。framesPerSecond 已乘一次 duration,随后 numValues 又乘 duration。该采样密度是否有意未验证。

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

19. 预渲染并原子缓存圆角位图,再用 blit 合成

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

做了什么:它以角方向和整数半径作为缓存 key。首次绘制一块黑色椭圆象限到 DrawingContext。后续调用通过 context.blt 粘贴该位图。缓存字典使用 Atomic 更新。

为什么这样做:聊天气泡和媒体缩略图会大量复用少数圆角半径。每张图重新建 path 和 fill 会浪费 CPU。预制 alpha 位图可快速做像素级合成。

关键代码摘录

ImageCorners.swift · L40–L64
    let cached: DrawingContext? = cachedCorners.with {
        return $0[corner]
    }

    if let cached = cached {
        return cached
    } else {
        let context = DrawingContext(size: CGSize(width: CGFloat(corner.radius), height: CGFloat(corner.radius)), clear: true)!

        context.withContext { c in
            c.clear(CGRect(origin: CGPoint(), size: CGSize(width: CGFloat(corner.radius), height: CGFloat(corner.radius))))
            c.setFillColor(UIColor.black.cgColor)
            switch corner {
            case let .TopLeft(radius):
                let rect = CGRect(origin: CGPoint(), size: CGSize(width: CGFloat(radius * 2), height: CGFloat(radius * 2)))
                c.fillEllipse(in: rect)
            case let .TopRight(radius):
                let rect = CGRect(origin: CGPoint(x: -CGFloat(radius), y: 0.0), size: CGSize(width: CGFloat(radius * 2), height: CGFloat(radius * 2)))
                c.fillEllipse(in: rect)
            case let .BottomLeft(radius):
                let rect = CGRect(origin: CGPoint(x: 0.0, y: -CGFloat(radius)), size: CGSize(width: CGFloat(radius * 2), height: CGFloat(radius * 2)))
                c.fillEllipse(in: rect)
            case let .BottomRight(radius):
                let rect = CGRect(origin: CGPoint(x: -CGFloat(radius), y: -CGFloat(radius)), size: CGSize(width: CGFloat(radius * 2), height: CGFloat(radius * 2)))
                c.fillEllipse(in: rect)

代价与风险:半径被截断为整数。缓存会随不同半径增长。首次并发 miss 可能重复构建上下文。无明显私有 API 风险。

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

20. 用随机相位的 additive 微位移避免重排项同频摆动

#03-20Telegram-iOSUIKit源码分析私有 API文本与输入

做了什么:它给位置和 Z 轴旋转各创建无限重复关键帧。每条动画的 beginTime 随机落在 0–0.24 秒。两条动画都 additive,因此不会覆盖基础布局或变换。

为什么这样做:重排模式中的多个 item 若同相位摇动,会显得机械。随机相位使静态布局看起来更有生命感。additive 避免干扰别处设置的 position 和 transform。

关键代码摘录

ShakeAnimation.swift · L67–L86
        position.beginTime = CFTimeInterval(Float(arc4random()).truncatingRemainder(dividingBy: Float(25)) / Float(100))
        position.isAdditive = true

        let transform = CAKeyframeAnimation.init(keyPath: "transform")
        transform.beginTime = 2.6
        transform.duration = 0.3
        transform.valueFunction = CAValueFunction(name: CAValueFunctionName.rotateZ)
        transform.values = [
            degreesToRadians(-1.0 * degreesRotation),
            degreesToRadians(degreesRotation),
            degreesToRadians(-1.0 * degreesRotation)
        ]
        transform.calculationMode = .linear
        transform.isRemovedOnCompletion = false
        transform.repeatCount = Float.greatestFiniteMagnitude
        transform.isAdditive = true
        transform.beginTime = CFTimeInterval(Float(arc4random()).truncatingRemainder(dividingBy: Float(25)) / Float(100))

        self.add(position, forKey: "shaking_position")
        self.add(transform, forKey: "shaking_rotation")

代价与风险:无限动画需要在退出重排模式时显式移除。随机数不保证视觉分布完全均匀。无明显私有 API 风险。

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

21. 用无布局 CATransformLayer 适配 AsyncDisplayKit

#03-21Telegram-iOSUIKit源码分析私有 APICore Animation

做了什么:ASTransformLayer 继承 CATransformLayer,并禁止内容、背景色和布局回调。ASTransformLayerNode 将它作为 AsyncDisplayKit 的 layer block 返回。

为什么这样做:CATransformLayer 只负责子层的 3D 合成。它不应承担普通绘制和自动布局。空实现防止节点框架为常规 layer 假设触发无用工作。

关键代码摘录

ASTransformLayerNode.swift · L5–L27
class ASTransformLayer: CATransformLayer {
    override var contents: Any? {
        get {
            return nil
        } set(value) {

        }
    }

    override var backgroundColor: CGColor? {
        get {
            return nil
        } set(value) {

        }
    }

    override func setNeedsLayout() {
    }

    override func layoutSublayers() {
    }
}

代价与风险:该节点不能直接绘制内容或设置背景。子层布局必须由外部代码维护。无明显私有 API 风险。

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

22. 让模拟器慢动画同时影响 UIKit 和显式 CA 动画

做了什么:模拟器构建直接链接 UIAnimationDragCoefficient。显式动画工具读取该系数,并将 CA speed 设为其倒数。真机固定返回 1.0。

为什么这样做:UIKit 的慢动画调试系数会改变 block 动画。手工 CAAnimation 默认不会同步。该桥接让两类动画在模拟器调试时保持相对时序。

关键代码摘录

Transition.swift · L5–L18 等 2 处
#if targetEnvironment(simulator)
@_silgen_name("UIAnimationDragCoefficient") func UIAnimationDragCoefficient() -> Float
#endif

public extension UIView {
    static var animationDurationFactor: Double {
        #if targetEnvironment(simulator)
        return Double(UIAnimationDragCoefficient())
        #else
        return 1.0
        #endif
    }
}

代价与风险:UIAnimationDragCoefficient 不是公开 iOS API。代码限制在 simulator。仍可能因 SDK 或模拟器运行时变化而链接失败。

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

本页目录