Telegram 技巧档案UIKIT · APPKIT
iOS · UIKit

列表与滚动

隐形 UIScrollView、π 旋转的倒序聊天与事务化布局。

主题范围:Telegram-iOS 的 ListView、GridNode、SparseItemGrid、AsyncListComponent 与 CoreList 滚动物理录制。

结论:ListView 把 UIScrollView 降为手势和物理引擎。它自己维护节点、可见窗口、锚点和动画。这样能支持聊天的倒序数据、双向增量加载和行高连续变化。源码没有直接说明弃用 UITableView 的原因。这里的取舍来自实现结构,属于推断。

1. 隐形 UIScrollView 只提供滚动物理

做了什么:ListView 隐藏 ListViewScroller。它仍复用 UIScrollView 的拖拽、减速和回弹。它不把内容放进 scroll view。它在 scrollViewDidScroll 中按增量平移自己的 ASDisplayNode。

为什么这样做:系统滚动手感无需重写。列表仍可按聊天语义摆放任意节点。它还可在顶部和底部独立加载。常规 UITableView 让滚动坐标、cell 布局和批量更新强绑定。这里把三者拆开了。

关键代码摘录

ListView.swift · L516–L534 等 2 处
self.transactionQueue.transactionCompleted = { [weak self] in
    if let strongSelf = self {
        strongSelf.updateVisibleItemRange()
    }
}
self.scroller.alwaysBounceVertical = true
self.scroller.contentSize = CGSize(width: 0.0, height: infiniteScrollSize * 2.0)
self.scroller.isHidden = true
self.scroller.delegate = self.wrappedScrollViewDelegate
self.scroller.scrollsToTop = false
self.view.addSubview(self.scroller)
self.scroller.panGestureRecognizer.cancelsTouchesInView = true
self.view.addGestureRecognizer(self.scroller.panGestureRecognizer)
// ...
self.transactionOffset += -deltaY
self.enqueueUpdateVisibleItems(synchronous: false)
for itemNode in self.itemNodes {
    itemNode.updateFrame(itemNode.frame.offsetBy(dx: 0.0, dy: -deltaY),
        within: self.visibleSize)
}

代价与风险:需要手写可见性、命中测试、辅助功能和滚动条。contentOffset 与节点 frame 必须保持同步。不同步会产生跳动。

证据链 · #04-01iOS 未做二进制验证
源码分析
已在 Telegram-iOS 固定提交中逐行确认,共 2 处源码位置。
报告摘要
怎么做到:代理 scrollViewDidScroll 累计增量,批量偏移可见节点。
二进制验证
未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。

2. 用 20,000pt 假内容高度承载半边未知数据

#04-02Telegram-iOSUIKit报告精选 04-2源码分析手势与滚动

做了什么:当已加载节点只碰到一个数据边界时,ListView 使用 infiniteScrollSize * 2。infiniteScrollSize 在初始化时为 10000.0。当两端都已加载时,它切回真实 completeHeight。

为什么这样做:聊天历史常只知道局部窗口。此映射让 UIKit 仍可继续滚动到未加载的一侧。节点可同时向两个方向补齐。常规有限 contentSize 会在未知边界过早顶住。

关键代码摘录

ListView.swift · L1726–L1745
if topItemFound && bottomItemFound {
    self.scroller.contentSize = CGSize(width: self.visibleSize.width, height: completeHeight)
    self.lastContentOffset = CGPoint(x: 0.0, y: -topItemEdge + self.tempTopInset)
    self.scroller.contentOffset = self.lastContentOffset
} else if topItemFound {
    self.scroller.contentSize = CGSize(width: self.visibleSize.width,
        height: infiniteScrollSize * 2.0)
    self.lastContentOffset = CGPoint(x: 0.0, y: -topItemEdge + self.tempTopInset)
    if self.scroller.contentOffset != self.lastContentOffset {
        self.scroller.contentOffset = self.lastContentOffset
    }
    self.lastContentOffset = self.scroller.contentOffset
} else if bottomItemFound {
    self.scroller.contentSize = CGSize(width: self.visibleSize.width,
        height: infiniteScrollSize * 2.0)
    self.lastContentOffset = CGPoint(x: 0.0,
        y: infiniteScrollSize * 2.0 - bottomItemEdge)
    self.scroller.contentOffset = self.lastContentOffset
}

代价与风险:这不是真实内容高度。代码必须反复重算并回写偏移。错误的边界判定会造成内容偏移突变。

证据链 · #04-02iOS 未做二进制验证
源码分析
已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
报告摘要
怎么做到:按 top/bottom 发现与否切换 contentSize 与 contentOffset。
二进制验证
未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。

3. 倒序聊天用父子两次 π 旋转保持文字正向

做了什么:历史列表的外层在 rotated 时旋转 π。聊天条目也在 rotated 时旋转 π。两次旋转让条目文字恢复正向。外层旋转把索引 0 的视觉位置变成屏幕底部。

为什么这样做:聊天可以把最新消息保留为列表首项。插入最新消息等价于靠近数据原点插入。这样更适合底部锚定和双向历史加载。常规正向列表通常需要持续维护“距底部”的特殊偏移。

关键代码摘录

ChatHistoryListNode.swift · L1025–L1045 等 2 处
self.usesCoreListBackend = useCoreListBackend
self.listView = ChatHistoryListNodeImpl.makeListView(
    rotated: rotated,
    useCoreListBackend: useCoreListBackend
)
super.init()
let _ = self.view
self.addSubnode(self.listView)
if rotated {
    self.transform = CATransform3DMakeRotation(
        CGFloat(Double.pi), 0.0, 0.0, 1.0)
}
// ...
public required init(rotated: Bool) {
    super.init(layerBacked: false, rotated: rotated)
    if rotated {
        self.transform = CATransform3DMakeRotation(
            CGFloat.pi, 0.0, 0.0, 1.0)
    }
}

代价与风险:x、y 方向、inset、滚动条边和辅助功能方向都要反向处理。源码也在 ListView.accessibilityScroll 中按 rotated 反转方向。坐标转换维护成本高。

证据链 · #04-03iOS 未做二进制验证
源码分析
已在 Telegram-iOS 固定提交中逐行确认,共 2 处源码位置。
报告摘要
怎么做到:构造列表与条目时条件性设置 CATransform3DMakeRotation。
二进制验证
未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。

4. 事务队列串行化异步布局完成顺序

#04-04Telegram-iOSUIKit报告精选 04-4源码分析性能与内存

做了什么:队列只启动第一个事务。当前事务完成后,endTransaction() 才启动下一个事务。完成回调不在主线程时,它强制切回主线程。

为什么这样做:行的异步测量可能乱序完成。若后一个差分先提交,它会基于过期几何更新节点。串行提交让每个状态转换都有确定前驱。

关键代码摘录

ListViewTransactionQueue.swift · L14–L35
public func addTransaction(_ transaction: @escaping ListViewTransaction) {
    precondition(Thread.isMainThread)
    let beginTransaction = self.transactions.count == 0
    self.transactions.append(transaction)
    if beginTransaction {
        transaction({ [weak self] in
            precondition(Thread.isMainThread)
            if Thread.isMainThread {
                if let strongSelf = self {
                    strongSelf.endTransaction()
                }
            } else {
                Queue.mainQueue().async {
                    if let strongSelf = self {
                        strongSelf.endTransaction()
                    }
                }
            }
        })
    } else {
        assert(true)
    }
}

代价与风险:慢事务会阻塞之后的事务。此设计避免乱序,但不取消过期工作。

证据链 · #04-04iOS 未做二进制验证
源码分析
已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
报告摘要
怎么做到:ListViewTransactionQueue 串联并强制回主线程收尾。
二进制验证
未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。

5. 布局可异步,节点写入必须回到主队列

#04-05Telegram-iOSUIKit报告精选 04-5源码分析性能与内存

做了什么:nodeForItem 把条目更新的调度器传给 ListViewItem。默认 async(_:) 使用 .userInteractive 全局队列。QueueLocalObject 把节点访问绑定到主队列。最终 apply 也断言主队列。

为什么这样做:文本测量和布局准备可以脱离主线程。UIView 和 AsyncDisplayKit 节点提交仍在主线程。这样把计算阶段和提交阶段分开。

关键代码摘录

ListView.swift · L1820–L1838
item.updateNode(async: { f in
    if synchronous {
        f()
    } else {
        self.async(f)
    }
}, node: {
    assert(Queue.mainQueue().isCurrent())
    return previousNode.syncWith({ $0 })
}, params: params, neighbors: neighbors, animation: updateAnimation,
completion: { (layout, apply) in
    if Thread.isMainThread {
        if synchronous {
            completion(previousNode, layout, {
                return (nil, { info in
                    assert(Queue.mainQueue().isCurrent())
                    previousNode.with({ $0.index = index; $0.appliedNeighbors = neighbors })
                    apply(info)
                })
            })
        } else {
            self.async {

代价与风险:条目实现必须遵守线程边界。错误地在 async 阶段访问 UIKit 会有竞态。开启 useMainQueueTransactions 后,后台收益会减少。

证据链 · #04-05iOS 未做二进制验证
源码分析
已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
报告摘要
怎么做到:QueueLocalObject 绑定节点到主队列,断言 apply 线程。
二进制验证
未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。

6. 快速滚动时只挂一个可见窗口刷新事务

#04-06Telegram-iOSUIKit源码分析手势与滚动

做了什么:enqueuedUpdateVisibleItems 合并多次滚动回调。事务期间,transactionOffset 继续累计。只有偏移仍有变化时,它才补挂下一次刷新。

为什么这样做:每个 contentOffset 回调都创建、测量和删除节点会淹没主线程。这个闸门把高频输入压缩为串行窗口刷新。

关键代码摘录

ListView.swift · L4618–L4642
private func enqueueUpdateVisibleItems(synchronous: Bool) {
    if !self.enqueuedUpdateVisibleItems {
        self.enqueuedUpdateVisibleItems = true
        self.transactionQueue.addTransaction({ [weak self] completion in
            if let strongSelf = self {
                strongSelf.transactionOffset = 0.0
                strongSelf.updateVisibleItemsTransaction(synchronous: synchronous, completion: {
                    var repeatUpdate = false
                    if let strongSelf = self {
                        repeatUpdate = abs(strongSelf.transactionOffset) > 0.00001
                        strongSelf.transactionOffset = 0.0
                        strongSelf.enqueuedUpdateVisibleItems = false
                    }
                    completion()
                    if repeatUpdate {
                        strongSelf.enqueueUpdateVisibleItems(synchronous: false)
                    }
                })
            }
        })
    }
}

代价与风险:节点加载会比手指位置晚一个事务。极慢设备上,这可能短暂显示较小的预载窗口。

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

7. 用静止条目锚住插入和删除后的视口

#04-07Telegram-iOSUIKit报告精选 04-6源码分析

做了什么:事务可以提供 stationaryItemRange。中间状态记录其中可用条目的旧 minY。新布局完成后,它整体平移所有 frame,使该条目回到旧屏幕位置。

为什么这样做:历史加载、删除和行高变化不应拖动用户正在看的消息。普通批量更新若只依赖索引路径,容易让顶部可见条目跳位。

关键代码摘录

ListViewIntermediateState.swift · L409–L425
} else if let (stationaryIndex, stationaryOffset) = self.stationaryOffset {
    for node in self.nodes {
        if node.index == stationaryIndex {
            let offset = stationaryOffset - node.frame.minY
            if abs(offset) > CGFloat.ulpOfOne {
                for i in 0 ..< self.nodes.count {
                    var frame = self.nodes[i].frame
                    frame = frame.offsetBy(dx: 0.0, dy: offset)
                    self.nodes[i].frame = frame
                }
            }
            break
        }
    }
}

代价与风险:调用方必须选对锚点。锚点本身被删除或测量错误时,只能退回其他定位策略。

证据链 · #04-07iOS 未做二进制验证
源码分析
已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
报告摘要
怎么做到:stationaryOffset 命中后批量偏移中间状态节点帧。
二进制验证
未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。

8. 删除动画用占位节点保住消失行的几何空间

做了什么:动画删除时,中间布局先移除逻辑节点。它立即放入保留旧 frame 的 .Placeholder。稍后的 replay 阶段可继续让旧节点消失。若旧节点已被复用,它会改用 ListViewTempItemNode。

为什么这样做:消失行仍占空间,兄弟节点才可连续位移。它还避免同一个节点既作为新行又作为旧行消失。常规直接移除会使删除帧和周围位移脱节。

关键代码摘录

ListViewIntermediateState.swift · L823–L843
mutating func removeNodeAtIndex(_ index: Int,
    direction: ListViewItemOperationDirectionHint?, animated: Bool,
    operations: inout [ListViewStateOperation]) {
    let node = self.nodes[index]
    if case let .Node(_, _, referenceNode, _) = node {
        let nodeFrame = node.frame
        self.nodes.remove(at: index)
        let offsetDirection: ListViewInsertionOffsetDirection
        if let direction = direction {
            offsetDirection = ListViewInsertionOffsetDirection(direction)
        } else {
            if nodeFrame.maxY < self.itemOffsetInsets.top + CGFloat.ulpOfOne {
                offsetDirection = .Down
            } else {
                offsetDirection = .Up
            }
        }
        operations.append(.Remove(index: index, offsetDirection: offsetDirection))
        if let referenceNode = referenceNode, animated {
            self.nodes.insert(.Placeholder(frame: nodeFrame), at: index)
            operations.append(.InsertDisappearingPlaceholder(
                index: index, referenceNode: referenceNode,
                offsetDirection: offsetDirection.inverted()))

代价与风险:状态机较复杂。占位节点必须及时移除。遗漏会留下零高度节点或错误 hit test。

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

9. 用 apparentHeight 分离真实布局高度和动画高度

做了什么:条目有最终 contentSize,也有逐帧变化的 apparentHeight。动画每帧计算高度差。列表依据条目位于视口上方还是下方,向相反一侧偏移兄弟节点。

为什么这样做:内容可先更新到最终布局。外框仍能从旧高度连续过渡。用户看到的锚点不会因内部文本变高而突然移动。

关键代码摘录

ListViewItemNode.swift · L569–L580 等 2 处
var requestUpdateVisibleItems = false
var index = 0
while index < self.itemNodes.count {
    let itemNode = self.itemNodes[index]
    let previousApparentHeight = itemNode.apparentHeight
    var invertOffsetDirection = false
    if itemNode.animate(timestamp: timestamp,
        invertOffsetDirection: &invertOffsetDirection) {
        continueAnimations = true
    }
    let updatedApparentHeight = itemNode.apparentHeight
    let apparentHeightDelta = updatedApparentHeight - previousApparentHeight
    if abs(apparentHeightDelta) > CGFloat.ulpOfOne {
        itemNode.updateFrame(itemNode.frame, within: self.visibleSize)
        let visualInsets = self.dynamicVisualInsets?() ?? self.visualInsets ?? self.insets
        if itemNode.apparentFrame.maxY <= visualInsets.top {
            offsetRanges.offset(IndexRange(first: 0, last: index),
                offset: -apparentHeightDelta)

代价与风险:每帧会遍历已加载节点。旋转列表还要处理 invertOffsetDirection。这比单纯 Auto Layout 高度动画更难验证。

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

10. 键盘和 inset 动画改为整体 sublayerTransform

#04-10Telegram-iOSUIKit源码分析Core Animation文本与输入

做了什么:尺寸或 inset 改变后,代码先提交最终 frame。它再给列表 layer 的 sublayerTransform 加一个反向平移动画。所有子节点一起从视觉旧位置过渡到新位置。

为什么这样做:键盘、导航栏或安全区变化会同时移动很多行。逐条添加位置动画成本高,也可能不同步。一个合成层动画能覆盖整批节点。

关键代码摘录

ListView.swift · L3328–L3351
let animation: CABasicAnimation
let animationCurve: ContainedViewLayoutTransitionCurve
let animationDuration: Double
switch updateSizeAndInsets.curve {
case let .Spring(duration):
    headerNodesTransition = (.animated(duration: duration, curve: .spring),
        false, -completeOffset)
    animationCurve = .spring
    let springAnimation = makeSpringAnimation("sublayerTransform", duration: duration)
    springAnimation.fromValue = NSValue(caTransform3D:
        CATransform3DMakeTranslation(0.0, -completeOffset, 0.0))
    springAnimation.toValue = NSValue(caTransform3D: CATransform3DIdentity)
    springAnimation.isRemovedOnCompletion = true
    let k = Float(UIView.animationDurationFactor())
    var speed: Float = 1.0
    if k != 0 && k != 1 {
        speed = Float(1.0) / k
    }
    if !duration.isZero {
        springAnimation.speed = speed * Float(springAnimation.duration / duration)
    }
    animationDuration = duration
    springAnimation.isAdditive = true
    animation = springAnimation

代价与风险:模型层和展示层会暂时不同。读取屏幕坐标时必须明确使用哪个层。源码在动画完成后再刷新可见性。

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

11. 以视口外 500pt 作为软预载窗口

#04-11Telegram-iOSUIKit源码分析性能与内存

做了什么:preloadPages 开启时,ListView 将 invisibleInset 设为 500.0。中间状态只在已加载总高度超过视口加两侧预载区时,移除窗口外节点。

为什么这样做:快速滚动需要下一屏节点已创建。严格只保留可见节点会频繁建销节点,并造成白帧。该窗口也让相邻条目布局更稳定。

关键代码摘录

ListViewIntermediateState.swift · L631–L653
mutating func removeInvisibleNodes(_ operations: inout [ListViewStateOperation]) {
    var i = 0
    var visibleItemNodeHeight: CGFloat = 0.0
    while i < self.nodes.count {
        visibleItemNodeHeight += self.nodes[i].frame.height
        i += 1
    }
    if visibleItemNodeHeight > (self.visibleSize.height + self.invisibleInset
        + self.invisibleInset) {
        i = self.nodes.count - 1
        while i >= 0 {
            let itemNode = self.nodes[i]
            let frame = itemNode.frame
            if frame.maxY < -self.invisibleInset
                || frame.origin.y > self.visibleSize.height + self.invisibleInset {
                operations.append(.Remove(index: i,
                    offsetDirection: frame.maxY < -self.invisibleInset ? .Down : .Up))
                self.nodes.remove(at: i)
            }
            i -= 1
        }
    }
}

代价与风险:内存消耗与行高成正比。高大媒体行会让 500pt 对应的节点数很少。固定阈值不一定适合所有设备。

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

12. 浮动 header 同时处理顶部、边缘和底部吸附

做了什么:header 不属于普通行。它根据逻辑分组的上下界计算独立 frame。它暴露吸附距离和归一化因子给 header 自己绘制。完整函数还处理 .bottom 和同一 stackingId 的避让。

为什么这样做:聊天日期、底部条和重叠 header 不只需要 UITableView 式顶部 sticky。独立节点可以跨多行、随旋转改变 inset 语义,并参与自定义动画。

关键代码摘录

ListView.swift · L4004–L4025
var stickLocationDistanceFactor: CGFloat = 0.0
var stickLocationDistance: CGFloat
switch item.stickDirection {
case .top:
    naturalY = lowerBound
    if item.isSticky {
        headerFrame = CGRect(origin: CGPoint(x: 0.0,
            y: min(max(upperDisplayBound, upperBound), lowerBound - itemHeaderHeight)),
            size: CGSize(width: self.visibleSize.width, height: itemHeaderHeight))
    } else {
        headerFrame = CGRect(origin: CGPoint(x: 0.0,
            y: min(upperBound, lowerBound - itemHeaderHeight)),
            size: CGSize(width: self.visibleSize.width, height: itemHeaderHeight))
    }
    stickLocationDistance = headerFrame.minY - upperBound
    stickLocationDistanceFactor = max(0.0,
        min(1.0, stickLocationDistance / itemHeaderHeight))
case .topEdge:
    naturalY = lowerBound
    headerFrame = CGRect(origin: CGPoint(x: 0.0,
        y: min(max(upperDisplayBound, upperBoundEdge - itemHeaderHeight),
            lowerBound - itemHeaderHeight)),
        size: CGSize(width: self.visibleSize.width, height: itemHeaderHeight))

代价与风险:代码要维护分组 affinity、z 顺序和附着条目。header 数量多时,分组和碰撞检查会增加开销。

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

13. 不知道总行高时仍绘制近似滚动条

#04-13Telegram-iOSUIKit源码分析手势与滚动

做了什么:自绘滚动条以 44pt 推算未加载行的高度。它再用首尾可见行的真实 frame 和真实高度校正当前视口边界。

为什么这样做:ListView 可能没有全量节点,也没有可靠总内容高度。仍显示长度大致正确的拇指,比禁用定位反馈更好。

关键代码摘录

ListView.swift · L4525–L4549
let averageRangeItemHeight: CGFloat = 44.0
var upperItemsHeight = floor(averageRangeItemHeight
    * CGFloat(scrollingIndicatorState.topItem.index))
var approximateContentHeight = CGFloat(scrollingIndicatorState.itemCount)
    * averageRangeItemHeight
if scrollingIndicatorState.topItem.index >= 0
    && self.items[scrollingIndicatorState.topItem.index].approximateHeight.isZero {
    upperItemsHeight -= averageRangeItemHeight
    approximateContentHeight -= averageRangeItemHeight
}
var convertedTopBoundary: CGFloat
if scrollingIndicatorState.topItem.offset < self.insets.top {
    convertedTopBoundary = (scrollingIndicatorState.topItem.offset
        - scrollingIndicatorState.insets.top) * averageRangeItemHeight
        / scrollingIndicatorState.topItem.height
} else {
    convertedTopBoundary = scrollingIndicatorState.topItem.offset
        - scrollingIndicatorState.insets.top
}
convertedTopBoundary -= upperItemsHeight
let approximateOffset = -convertedTopBoundary
var convertedBottomBoundary: CGFloat = 0.0
if scrollingIndicatorState.bottomItem.offset
    > self.visibleSize.height - self.insets.bottom {

代价与风险:行高差异大时,拇指进度会不准确。它是近似算法,不是精确滚动位置。

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

14. 外部速度可注入一段像 UIKit 的减速

做了什么:transferVelocity(_:) 用 ConstantDisplayLinkAnimator 驱动指数衰减。它保存亚像素余数,只把整像素写入偏移。ListViewScroller.isDecelerating 在 forceDecelerating 为真时报告减速中。

为什么这样做:外部转场或自定义手势可把速度交给列表。逻辑减速状态让 header、滚动条和结束回调仍按正常滚动处理。像素量化避免每帧小数抖动。

关键代码摘录

ListView.swift · L951–L969 等 2 处
public func transferVelocity(_ velocity: CGFloat) {
    self.decelerationAnimator?.isPaused = true
    let startTime = CACurrentMediaTime()
    let decelerationRate: CGFloat = 0.998
    self.scroller.forceDecelerating = true
    self.decelerationAnimator = ConstantDisplayLinkAnimator(update: { [weak self] in
        guard let strongSelf = self else {
            return
        }
        let t = CACurrentMediaTime() - startTime
        var currentVelocity = velocity * 15.0
            * CGFloat(pow(Double(decelerationRate), 1000.0 * t))
        strongSelf.accumulatedTransferVelocityOffset += currentVelocity
        let signFactor: CGFloat = strongSelf.accumulatedTransferVelocityOffset >= 0.0 ? 1.0 : -1.0
        let remainder = abs(strongSelf.accumulatedTransferVelocityOffset)
            .remainder(dividingBy: UIScreenPixel)
        var currentOffset = strongSelf.scroller.contentOffset
        let addedDela = strongSelf.accumulatedTransferVelocityOffset - remainder * signFactor
        currentOffset.y += addedDela
        strongSelf.accumulatedTransferVelocityOffset -= addedDela

代价与风险:参数 0.998、15.0 和结束阈值是手工模型。它未复用 UIKit 的全部边界回弹行为。不同刷新率上的手感需要验证。

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

15. GridNode 给 UIScrollView 换成不响应绘制失效的 layer

#04-15Telegram-iOSUIKit源码分析Core Animation手势与滚动性能与内存

做了什么:GridNodeScrollerView 指定 GridNodeScrollerLayer 为 backing layer。该 layer 覆盖 setNeedsDisplay() 且不执行任何操作。

为什么这样做:GridNode 自己用子节点布局网格。滚动宿主不承担内容绘制。推断:这可避免宿主 layer 的无效重绘路径。

关键代码摘录

GridNodeScroller.swift · L4–L28
private class GridNodeScrollerLayer: CALayer {
    override func setNeedsDisplay() {
    }
}
public class GridNodeScrollerView: UIScrollView {
    override public class var layerClass: AnyClass {
        return GridNodeScrollerLayer.self
    }
    override public init(frame: CGRect) {
        super.init(frame: frame)
        if #available(iOSApplicationExtension 11.0, iOS 11.0, *) {
            self.contentInsetAdjustmentBehavior = .never
        }
    }
    required public init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    override public func touchesShouldCancel(in view: UIView) -> Bool {
        return true
    }
}

代价与风险:这是非典型 UIScrollView layer 替换。源码没有给出性能数据。实际收益未验证。未来 UIKit 对 backing layer 的假设可能改变。

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

16. GridNode 先切出 preload 窗口,再创建节点

做了什么:GridNode 先从完整 itemLayout 中筛出显示区加 preloadSize。后续 applyPresentationLayoutTransition 只对 presentationItems 创建或更新 GridItemNode。

为什么这样做:网格可以拥有完整几何,却不保留全部视图。preloadSize 在快速滚动前预先创建相邻项目。常规一次创建所有缩略图会快速耗尽内存。

关键代码摘录

GridNode.swift · L815–L849
let lowerDisplayBound = contentOffset.y - self.gridLayout.insets.top
    - self.gridLayout.preloadSize
let upperDisplayBound = contentOffset.y + self.gridLayout.insets.bottom
    + self.gridLayout.size.height + self.gridLayout.preloadSize
var presentationItems: [GridNodePresentationItem] = []
var validSections = Set<WrappedGridSection>()
for item in self.itemLayout.items {
    if item.frame.maxY < lowerDisplayBound {
        continue
    }
    if item.frame.minY > upperDisplayBound {
        break
    }
    presentationItems.append(item)
    if self.floatingSections {
        if let section = self.items[item.index].section {
            validSections.insert(WrappedGridSection(section))
        }
    }
}
var presentationSections: [GridNodePresentationSection] = []
for section in self.itemLayout.sections {

代价与风险:完整 frame 数组仍会随数据量增长。这个实现假定项目按 y 单调排序,才可以在越过上界时 break。

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

17. 网格动画先固化屏幕坐标,再做 additive 位移

做了什么:布局变更前,代码把项目 frame 减去旧 contentOffset。这得到屏幕坐标。新布局确定后,它计算幸存项目的共同偏差,并以 additive position 动画补偿。

为什么这样做:动画期间,滚动偏移和项目 frame 都可能变化。直接比较内容坐标会让项目跳动。屏幕坐标可把视觉连续性与内容坐标变化解耦。

关键代码摘录

GridNode.swift · L887–L899 等 2 处
if saveItemFrames {
    var itemFrames: [WrappedGridItemNode: CGRect] = [:]
    let contentOffset = self.scrollView.contentOffset
    for (_, itemNode) in self.itemNodes {
        itemFrames[WrappedGridItemNode(node: itemNode)] = itemNode.frame
            .offsetBy(dx: 0.0, dy: -contentOffset.y)
    }
    for (_, sectionNode) in self.sectionNodes {
        itemFrames[WrappedGridItemNode(node: sectionNode)] = sectionNode.frame
            .offsetBy(dx: 0.0, dy: -contentOffset.y)
    }
    for itemNode in removedNodes {
        itemFrames[WrappedGridItemNode(node: itemNode)] = itemNode.frame
            .offsetBy(dx: 0.0, dy: -contentOffset.y)
    }
    previousItemFrames = itemFrames
}
if let offset = offset {
    // `timingFunction` 与 `mediaTimingFunction` 来自 `curve`。
    for (index, itemNode) in self.itemNodes where existingItemIndices.contains(index) {
        itemNode.layer.animatePosition(from: CGPoint(x: 0.0, y: offset),
            to: CGPoint(), duration: duration, timingFunction: timingFunction,
            mediaTimingFunction: mediaTimingFunction, additive: true)
    }
}

代价与风险:它需要至少一个共同项目。没有共同项目时,代码用上下边界和方向猜测偏移。复杂布局变更时可能不够自然。

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

18. 网格用线性分区做“相册式”均衡行

做了什么:.balanced 将每个项目的宽高比量化为权重。它按理想行高估算行数。它再用 linearPartitionForWeights 切分连续项目。

为什么这样做:每行的比例和接近视口宽度。随后代码按该行总比例分配宽度。相册缩略图不会出现简单固定列的巨大空白。

关键代码摘录

GridNode.swift · L594–L606
case let .balanced(idealHeight):
    var weights: [Int] = []
    for item in self.items {
        weights.append(Int(item.aspectRatio * 100))
    }
    var totalItemSize: CGFloat = 0.0
    for i in 0 ..< self.items.count {
        totalItemSize += self.items[i].aspectRatio * idealHeight
    }
    let numberOfRows = max(Int(round(totalItemSize / gridLayout.size.width)), 1)
    let partition = linearPartitionForWeights(weights,
        numberOfPartitions:numberOfRows)
    var i = 0
    var offset = CGPoint(x: 0.0, y: 0.0)

代价与风险:全局分区需读取所有项目。大数据集频繁重排可能昂贵。比例被乘以 100 再截断,极端比例会损失精度。

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

19. 稀疏网格把未加载索引视为洞,并定向拉取

做了什么:SparseItemGrid.Items 持有总数与局部项目。可见索引没有项目时,代码查找最近 HoleAnchor。它只为第一个洞发出一次 .toLower 加载请求。

为什么这样做:媒体列表不必先物化完整数组。用户滚到缺口时才加载该区域。网格仍可按总索引计算稳定位置。

关键代码摘录

SparseItemGrid.swift · L1305–L1329
if !items.items.isEmpty {
    let visibleBounds = self.scrollView.bounds
    let visibleRange = layout.visibleItemRange(for: visibleBounds, count: items.count)
    for index in visibleRange.minIndex ... visibleRange.maxIndex {
        if items.item(at: index) == nil {
            let closestHole = items.closestHole(to: index)
            var closestAnchor: HoleAnchor?
            /*if let closestItem = items.closestItem(at: index), let closestHole = closestHole {
                if abs(closestItem.index - index) < abs(closestHole.index - index) {
                    closestAnchor = closestItem.holeAnchor
                } else {
                    closestAnchor = closestHole
                }
            } else if let closestItem = closestItem {
                closestAnchor = closestItem.holeAnchor
            } else if let closestHole = closestHole {*/
                closestAnchor = closestHole
            //}
            if let closestAnchor = closestAnchor {
                self.maybeLoadHoleAnchor(closestAnchor, .toLower)
            }
            break
        }
    }
}

代价与风险:加载方向固定为 .toLower。代码中保留了更复杂的“最近项目或洞”选择逻辑,但目前被注释。具体服务端分页效果未验证。

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

20. 稀疏网格按稳定 id 维护可见 layer,而不是 cell 池

#04-20Telegram-iOSUIKit源码分析Core Animation手势与滚动性能与内存

做了什么:visibleItems 以业务稳定 id 保存 VisibleItem。仍可见的 id 只更新。首次进入窗口的项目才创建 layer 或 view,并进入批量 bindLayers。

为什么这样做:媒体项目可直接使用 CALayer。它避免了 UICollectionViewCell 的壳层和通用复用协议。重排时也能按 id 保持正在拖动的 layer。

关键代码摘录

SparseItemGrid.swift · L1095–L1119
let itemLayer: VisibleItem
if let current = self.visibleItems[item.id] {
    itemLayer = current
    updateLayers.append((itemLayer, index))
} else {
    itemLayer = VisibleItem(
        layer: items.itemBinding.createLayer(item: item),
        view: items.itemBinding.createView()
    )
    itemLayer.layer?.masksToBounds = true
    self.visibleItems[item.id] = itemLayer
    bindItems.append(item)
    bindLayers.append(itemLayer)
    if let layer = itemLayer.layer {
        if let reorderingItem = self.reorderingItem,
            let visibleReorderingItem = self.visibleItems[reorderingItem.id] {
            self.scrollView.layer.insertSublayer(layer,
                below: visibleReorderingItem.layer)
        } else {
            self.scrollView.layer.addSublayer(layer)
        }
    } else if let view = itemLayer.view {
        self.scrollView.addSubview(view)
    }
}

代价与风险:这不是跨项目复用池。离开窗口的 layer 会解绑并移除。高频来回滚动仍可能反复创建复杂 layer。

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

21. 用一张长渐变图和 contentsRect 驱动全局 shimmer

#04-21Telegram-iOSUIKit源码分析Core Animation性能与内存

做了什么:Shimmer 缓存一张 1 × 320 的渐变图。每个占位 layer 只裁剪不同的 contentsRect。它动画化 contentsRect.origin.y,而不是每帧重绘渐变。

为什么这样做:不同网格单元能共享同一条视觉连续的扫光。Core Animation 可在合成阶段移动纹理。CPU 不必为每个单元重复画渐变。

关键代码摘录

SparseItemGrid.swift · L131–L145
if let image = self.image {
    layer.contents = image.cgImage
    let shiftedContentsRect = CGRect(
        origin: CGPoint(x: 0.0, y: frame.minY / containerSize.height),
        size: CGSize(width: 1.0, height: frame.height / containerSize.height)
    )
    layer.contentsRect = shiftedContentsRect
    if layer.animation(forKey: "shimmer") == nil {
        let animation = CABasicAnimation(keyPath: "contentsRect.origin.y")
        animation.fromValue = 1.0 as NSNumber
        animation.toValue = -1.0 as NSNumber
        animation.isAdditive = true
        animation.repeatCount = .infinity
        animation.duration = 0.8
        animation.beginTime = layer.convertTime(1.0, from: nil)
        layer.add(animation, forKey: "shimmer")
    }
}

代价与风险:每个可见占位仍有一个无限 CA 动画。container 尺寸变化时,contentsRect 的归一化计算必须同步更新。

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

22. AsyncListComponent 旋转 90° 复用纵向 ListView

做了什么:横向模式不新建横向虚拟列表。它旋转整个 ListView 负 π/2,交换宽高,再旋转内部 contentContainer 正 π/2。

为什么这样做:同一套可见窗口、差分、重排和行为可服务两个方向。实现不必维护两份虚拟化逻辑。

关键代码摘录

AsyncListComponent.swift · L547–L558
let listSize: CGSize
let listInsets: UIEdgeInsets
switch component.direction {
case .vertical:
    self.listNode.transform = CATransform3DIdentity
    listSize = CGSize(width: availableSize.width, height: availableSize.height)
    listInsets = component.insets
case .horizontal:
    self.listNode.transform = CATransform3DMakeRotation(
        -CGFloat.pi / 2.0, 0.0, 0.0, 1.0)
    listSize = CGSize(width: availableSize.height, height: availableSize.width)
    listInsets = UIEdgeInsets(top: component.insets.left,
        left: component.insets.top, bottom: component.insets.right,
        right: component.insets.bottom)
}

代价与风险:inset 和 frame 要换轴。VisibleItems 也要重新映射 frame。旋转坐标会增加辅助功能和手势调试难度。

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

23. CoreList 用 IMP swizzle 录制 UIKit 物理真值

#04-23Telegram-iOSUIKit源码分析私有 API运行时 hack手势与滚动

做了什么:CoreList 的 demo 在调试时替换 UIScrollView.setContentOffset:。它还替换私有 selector,以记录橡皮筋输入输出。录制数据供纯 Swift 的 ScrollReplay 回放。

为什么这样做:自研物理不必凭感觉拟合。它可逐帧比较 UIKit 的真实偏移、手势速度和回弹结果。该目录还明确把记录定义为可编码的手势样本。

关键代码摘录

UIScrollViewPhysicsSwizzler.swift · L108–L132
private static func swizzleSetContentOffset() {
    let sel = NSSelectorFromString("setContentOffset:")
    guard let method = class_getInstanceMethod(UIScrollView.self, sel) else { return }
    typealias Fn = @convention(c) (AnyObject, Selector, CGPoint) -> Void
    let original = unsafeBitCast(method_getImplementation(method), to: Fn.self)
    let block: @convention(block) (AnyObject, CGPoint) -> Void = { obj, offset in
        CaptureSink.current?.recordOffsetWrite(offset, from: obj)
        original(obj, sel, offset)
    }
    method_setImplementation(method, imp_implementationWithBlock(block))
}
private static func swizzleRubberBand() {
    let sel = NSSelectorFromString("_rubberBandOffsetForOffset:maxOffset:minOffset:range:outside:")
    guard let method = class_getInstanceMethod(UIScrollView.self, sel) else { return }
    typealias Fn = @convention(c) (AnyObject, Selector, CGFloat, CGFloat, CGFloat,
        CGFloat, UnsafeMutablePointer<ObjCBool>?) -> CGFloat
    let original = unsafeBitCast(method_getImplementation(method), to: Fn.self)
    let block: @convention(block) (AnyObject, CGFloat, CGFloat, CGFloat, CGFloat,
        UnsafeMutablePointer<ObjCBool>?) -> CGFloat = {
        obj, offset, maxOff, minOff, range, outside in
        let out = original(obj, sel, offset, maxOff, minOff, range, outside)
        CaptureSink.current?.recordRubberBand(
            .init(offset: offset, min: minOff, max: maxOff, range: range, out: out), from: obj)
        return out
    }

代价与风险:私有 API 的真实名字是 _rubberBandOffsetForOffset:maxOffset:minOffset:range:outside:。它没有混淆。源码以 NSSelectorFromString 直接写出该字符串。代码注释称此工具“Debug-only; never shipped”。但仅凭本范围无法验证最终构建是否排除它。若进入 App Store 构建,存在审核和兼容性风险。

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

本页目录