列表与滚动
隐形 UIScrollView、π 旋转的倒序聊天与事务化布局。
主题范围:Telegram-iOS 的 ListView、GridNode、SparseItemGrid、AsyncListComponent 与 CoreList 滚动物理录制。
结论:ListView 把 UIScrollView 降为手势和物理引擎。它自己维护节点、可见窗口、锚点和动画。这样能支持聊天的倒序数据、双向增量加载和行高连续变化。源码没有直接说明弃用 UITableView 的原因。这里的取舍来自实现结构,属于推断。
1. 隐形 UIScrollView 只提供滚动物理
做了什么:ListView 隐藏 ListViewScroller。它仍复用 UIScrollView 的拖拽、减速和回弹。它不把内容放进 scroll view。它在 scrollViewDidScroll 中按增量平移自己的 ASDisplayNode。
为什么这样做:系统滚动手感无需重写。列表仍可按聊天语义摆放任意节点。它还可在顶部和底部独立加载。常规 UITableView 让滚动坐标、cell 布局和批量更新强绑定。这里把三者拆开了。
关键代码摘录
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 必须保持同步。不同步会产生跳动。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 2 处源码位置。
- 报告摘要
- 怎么做到:代理
scrollViewDidScroll累计增量,批量偏移可见节点。 - 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
2. 用 20,000pt 假内容高度承载半边未知数据
做了什么:当已加载节点只碰到一个数据边界时,ListView 使用 infiniteScrollSize * 2。infiniteScrollSize 在初始化时为 10000.0。当两端都已加载时,它切回真实 completeHeight。
为什么这样做:聊天历史常只知道局部窗口。此映射让 UIKit 仍可继续滚动到未加载的一侧。节点可同时向两个方向补齐。常规有限 contentSize 会在未知边界过早顶住。
关键代码摘录
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
}代价与风险:这不是真实内容高度。代码必须反复重算并回写偏移。错误的边界判定会造成内容偏移突变。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 报告摘要
- 怎么做到:按 top/bottom 发现与否切换
contentSize与contentOffset。 - 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
3. 倒序聊天用父子两次 π 旋转保持文字正向
做了什么:历史列表的外层在 rotated 时旋转 π。聊天条目也在 rotated 时旋转 π。两次旋转让条目文字恢复正向。外层旋转把索引 0 的视觉位置变成屏幕底部。
为什么这样做:聊天可以把最新消息保留为列表首项。插入最新消息等价于靠近数据原点插入。这样更适合底部锚定和双向历史加载。常规正向列表通常需要持续维护“距底部”的特殊偏移。
关键代码摘录
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 反转方向。坐标转换维护成本高。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 2 处源码位置。
- 报告摘要
- 怎么做到:构造列表与条目时条件性设置
CATransform3DMakeRotation。 - 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
4. 事务队列串行化异步布局完成顺序
做了什么:队列只启动第一个事务。当前事务完成后,endTransaction() 才启动下一个事务。完成回调不在主线程时,它强制切回主线程。
为什么这样做:行的异步测量可能乱序完成。若后一个差分先提交,它会基于过期几何更新节点。串行提交让每个状态转换都有确定前驱。
关键代码摘录
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)
}
}代价与风险:慢事务会阻塞之后的事务。此设计避免乱序,但不取消过期工作。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 报告摘要
- 怎么做到:
ListViewTransactionQueue串联并强制回主线程收尾。 - 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
5. 布局可异步,节点写入必须回到主队列
做了什么:nodeForItem 把条目更新的调度器传给 ListViewItem。默认 async(_:) 使用 .userInteractive 全局队列。QueueLocalObject 把节点访问绑定到主队列。最终 apply 也断言主队列。
为什么这样做:文本测量和布局准备可以脱离主线程。UIView 和 AsyncDisplayKit 节点提交仍在主线程。这样把计算阶段和提交阶段分开。
关键代码摘录
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 后,后台收益会减少。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 报告摘要
- 怎么做到:
QueueLocalObject绑定节点到主队列,断言 apply 线程。 - 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
6. 快速滚动时只挂一个可见窗口刷新事务
做了什么:enqueuedUpdateVisibleItems 合并多次滚动回调。事务期间,transactionOffset 继续累计。只有偏移仍有变化时,它才补挂下一次刷新。
为什么这样做:每个 contentOffset 回调都创建、测量和删除节点会淹没主线程。这个闸门把高频输入压缩为串行窗口刷新。
关键代码摘录
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)
}
})
}
})
}
}代价与风险:节点加载会比手指位置晚一个事务。极慢设备上,这可能短暂显示较小的预载窗口。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
7. 用静止条目锚住插入和删除后的视口
做了什么:事务可以提供 stationaryItemRange。中间状态记录其中可用条目的旧 minY。新布局完成后,它整体平移所有 frame,使该条目回到旧屏幕位置。
为什么这样做:历史加载、删除和行高变化不应拖动用户正在看的消息。普通批量更新若只依赖索引路径,容易让顶部可见条目跳位。
关键代码摘录
} 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
}
}
}代价与风险:调用方必须选对锚点。锚点本身被删除或测量错误时,只能退回其他定位策略。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 报告摘要
- 怎么做到:
stationaryOffset命中后批量偏移中间状态节点帧。 - 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
8. 删除动画用占位节点保住消失行的几何空间
做了什么:动画删除时,中间布局先移除逻辑节点。它立即放入保留旧 frame 的 .Placeholder。稍后的 replay 阶段可继续让旧节点消失。若旧节点已被复用,它会改用 ListViewTempItemNode。
为什么这样做:消失行仍占空间,兄弟节点才可连续位移。它还避免同一个节点既作为新行又作为旧行消失。常规直接移除会使删除帧和周围位移脱节。
关键代码摘录
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。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
9. 用 apparentHeight 分离真实布局高度和动画高度
做了什么:条目有最终 contentSize,也有逐帧变化的 apparentHeight。动画每帧计算高度差。列表依据条目位于视口上方还是下方,向相反一侧偏移兄弟节点。
为什么这样做:内容可先更新到最终布局。外框仍能从旧高度连续过渡。用户看到的锚点不会因内部文本变高而突然移动。
关键代码摘录
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 高度动画更难验证。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 2 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
10. 键盘和 inset 动画改为整体 sublayerTransform
做了什么:尺寸或 inset 改变后,代码先提交最终 frame。它再给列表 layer 的 sublayerTransform 加一个反向平移动画。所有子节点一起从视觉旧位置过渡到新位置。
为什么这样做:键盘、导航栏或安全区变化会同时移动很多行。逐条添加位置动画成本高,也可能不同步。一个合成层动画能覆盖整批节点。
关键代码摘录
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代价与风险:模型层和展示层会暂时不同。读取屏幕坐标时必须明确使用哪个层。源码在动画完成后再刷新可见性。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
11. 以视口外 500pt 作为软预载窗口
做了什么:preloadPages 开启时,ListView 将 invisibleInset 设为 500.0。中间状态只在已加载总高度超过视口加两侧预载区时,移除窗口外节点。
为什么这样做:快速滚动需要下一屏节点已创建。严格只保留可见节点会频繁建销节点,并造成白帧。该窗口也让相邻条目布局更稳定。
关键代码摘录
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 对应的节点数很少。固定阈值不一定适合所有设备。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
12. 浮动 header 同时处理顶部、边缘和底部吸附
做了什么:header 不属于普通行。它根据逻辑分组的上下界计算独立 frame。它暴露吸附距离和归一化因子给 header 自己绘制。完整函数还处理 .bottom 和同一 stackingId 的避让。
为什么这样做:聊天日期、底部条和重叠 header 不只需要 UITableView 式顶部 sticky。独立节点可以跨多行、随旋转改变 inset 语义,并参与自定义动画。
关键代码摘录
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 数量多时,分组和碰撞检查会增加开销。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
13. 不知道总行高时仍绘制近似滚动条
做了什么:自绘滚动条以 44pt 推算未加载行的高度。它再用首尾可见行的真实 frame 和真实高度校正当前视口边界。
为什么这样做:ListView 可能没有全量节点,也没有可靠总内容高度。仍显示长度大致正确的拇指,比禁用定位反馈更好。
关键代码摘录
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 {代价与风险:行高差异大时,拇指进度会不准确。它是近似算法,不是精确滚动位置。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
14. 外部速度可注入一段像 UIKit 的减速
做了什么:transferVelocity(_:) 用 ConstantDisplayLinkAnimator 驱动指数衰减。它保存亚像素余数,只把整像素写入偏移。ListViewScroller.isDecelerating 在 forceDecelerating 为真时报告减速中。
为什么这样做:外部转场或自定义手势可把速度交给列表。逻辑减速状态让 header、滚动条和结束回调仍按正常滚动处理。像素量化避免每帧小数抖动。
关键代码摘录
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 的全部边界回弹行为。不同刷新率上的手感需要验证。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 2 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
15. GridNode 给 UIScrollView 换成不响应绘制失效的 layer
做了什么:GridNodeScrollerView 指定 GridNodeScrollerLayer 为 backing layer。该 layer 覆盖 setNeedsDisplay() 且不执行任何操作。
为什么这样做:GridNode 自己用子节点布局网格。滚动宿主不承担内容绘制。推断:这可避免宿主 layer 的无效重绘路径。
关键代码摘录
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 的假设可能改变。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
16. GridNode 先切出 preload 窗口,再创建节点
做了什么:GridNode 先从完整 itemLayout 中筛出显示区加 preloadSize。后续 applyPresentationLayoutTransition 只对 presentationItems 创建或更新 GridItemNode。
为什么这样做:网格可以拥有完整几何,却不保留全部视图。preloadSize 在快速滚动前预先创建相邻项目。常规一次创建所有缩略图会快速耗尽内存。
关键代码摘录
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。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
17. 网格动画先固化屏幕坐标,再做 additive 位移
做了什么:布局变更前,代码把项目 frame 减去旧 contentOffset。这得到屏幕坐标。新布局确定后,它计算幸存项目的共同偏差,并以 additive position 动画补偿。
为什么这样做:动画期间,滚动偏移和项目 frame 都可能变化。直接比较内容坐标会让项目跳动。屏幕坐标可把视觉连续性与内容坐标变化解耦。
关键代码摘录
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)
}
}代价与风险:它需要至少一个共同项目。没有共同项目时,代码用上下边界和方向猜测偏移。复杂布局变更时可能不够自然。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 2 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
18. 网格用线性分区做“相册式”均衡行
做了什么:.balanced 将每个项目的宽高比量化为权重。它按理想行高估算行数。它再用 linearPartitionForWeights 切分连续项目。
为什么这样做:每行的比例和接近视口宽度。随后代码按该行总比例分配宽度。相册缩略图不会出现简单固定列的巨大空白。
关键代码摘录
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 再截断,极端比例会损失精度。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
19. 稀疏网格把未加载索引视为洞,并定向拉取
做了什么:SparseItemGrid.Items 持有总数与局部项目。可见索引没有项目时,代码查找最近 HoleAnchor。它只为第一个洞发出一次 .toLower 加载请求。
为什么这样做:媒体列表不必先物化完整数组。用户滚到缺口时才加载该区域。网格仍可按总索引计算稳定位置。
关键代码摘录
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。代码中保留了更复杂的“最近项目或洞”选择逻辑,但目前被注释。具体服务端分页效果未验证。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
20. 稀疏网格按稳定 id 维护可见 layer,而不是 cell 池
做了什么:visibleItems 以业务稳定 id 保存 VisibleItem。仍可见的 id 只更新。首次进入窗口的项目才创建 layer 或 view,并进入批量 bindLayers。
为什么这样做:媒体项目可直接使用 CALayer。它避免了 UICollectionViewCell 的壳层和通用复用协议。重排时也能按 id 保持正在拖动的 layer。
关键代码摘录
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。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
21. 用一张长渐变图和 contentsRect 驱动全局 shimmer
做了什么:Shimmer 缓存一张 1 × 320 的渐变图。每个占位 layer 只裁剪不同的 contentsRect。它动画化 contentsRect.origin.y,而不是每帧重绘渐变。
为什么这样做:不同网格单元能共享同一条视觉连续的扫光。Core Animation 可在合成阶段移动纹理。CPU 不必为每个单元重复画渐变。
关键代码摘录
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 的归一化计算必须同步更新。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
22. AsyncListComponent 旋转 90° 复用纵向 ListView
做了什么:横向模式不新建横向虚拟列表。它旋转整个 ListView 负 π/2,交换宽高,再旋转内部 contentContainer 正 π/2。
为什么这样做:同一套可见窗口、差分、重排和行为可服务两个方向。实现不必维护两份虚拟化逻辑。
关键代码摘录
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。旋转坐标会增加辅助功能和手势调试难度。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
23. CoreList 用 IMP swizzle 录制 UIKit 物理真值
做了什么:CoreList 的 demo 在调试时替换 UIScrollView.setContentOffset:。它还替换私有 selector,以记录橡皮筋输入输出。录制数据供纯 Swift 的 ScrollReplay 回放。
为什么这样做:自研物理不必凭感觉拟合。它可逐帧比较 UIKit 的真实偏移、手势速度和回弹结果。该目录还明确把记录定义为可编码的手势样本。
关键代码摘录
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 构建,存在审核和兼容性风险。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。