ASDK 与文本
AsyncDisplayKit 异步绘制、自绘选择与 IME 修复。
范围:Telegram-iOS 的 AsyncDisplayKit 分叉、CoreText 文本节点与 RichTextEditor 的文本渲染和输入层。
结论:该分叉把大量系统文本行为移到自有布局层。它用后台绘制、CoreText 行级几何、真实附件视图和自绘 UITextInput,避开 UIKit 的多处缺陷。以下按意外程度和工程价值排序。代码来自 Telegram-iOS 提交 f1dd7a2dbd02cbbf513e75d5695d8d36d1cf5838。
1. 不安装系统选择交互,完全自绘选择 UI
做了什么:DocumentCanvasView 实现 UITextInput,但不创建 UITextSelectionDisplayInteraction。它保留 UIEditMenuInteraction,并自行绘制光标、选区和两个手柄。它只在一次放大镜拖动期间创建临时交互对象。
为什么这样做:源码记录 iOS 18+/26 会留下系统默认手柄。自定义空视图无法抑制这些手柄。自绘 UI 还能使表格内的光标、手柄和选区跟随横向滚动及回弹。
关键代码摘录
if #available(iOS 16.0, *) {
installEditMenuInteraction()
}
// We deliberately do NOT add a `UITextSelectionDisplayInteraction`.
// The canvas draws ALL of its own selection visuals.
// ...
// on iOS 18+/26 the interaction installs its OWN default selection chrome.
// assigning custom no-draw views no longer suppresses default lollipops.
// ...
// Not creating the interaction at all removes that leak at the source.
// ...
// A FRESH interaction is created per loupe drag and torn down on release.代价与风险:这是完整的系统文本交互重实现。它依赖多个 iOS 版本的实机行为。系统类名仅出现在注释中,未调用私有 API。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 报告摘要
- 怎么做到:选择/光标/手柄均由
DocumentCanvasView绘制管理。 - 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
2. 向键盘投影“虚拟换行”,修复韩文和 CJK 跨段重组
做了什么:结构化文档内部使用连续的全局 UTF-16 位置。它向 UITextInput.text(in:) 投影文本时,才在跨顶层段落处插入 \n。它把 emoji 和公式附件还原为可读的替代文本。
为什么这样做:键盘会通过 text(in:) 读取上下文。没有换行时,韩文和 CJK 输入法把上下段当成同一行。源码明确记录过尾辅音迁移到上一行的错误。表格单元格不插入分隔符,因此表格仍是一个编辑面。
关键代码摘录
func legacyPlainText(globalFrom: Int, globalTo: Int) -> String? {
let lo = clamp(min(globalFrom, globalTo))
let hi = clamp(max(globalFrom, globalTo))
var result = ""
forEachLeafRegionInRange(lo, hi) { region, needsSeparator in
if needsSeparator { result += "\n" }
let rStart = region.globalStart
let rEnd = region.globalStart + region.length
let a = max(lo, rStart)
let b = min(hi, rEnd)
guard a < b else { return }
let attr = region.layout.attributedString
let ns = attr.string as NSString
let slice = NSRange(location: a - rStart, length: b - a)
attr.enumerateAttribute(.attachment, in: slice, options: []) { value, sub, _ in
if let att = value as? EmojiTextAttachment {
result += att.ref.altText ?? ""
} else if let att = value as? FormulaTextAttachment {
result += att.latex
} else { result += ns.substring(with: sub) }
}
}
return result
}代价与风险:模型位置与键盘文本位置不是同一个序列。任何新增叶子区域都必须遵守同一分隔规则。否则输入法和复制结果会分歧。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 报告摘要
- 怎么做到:遍历叶区间,投影到
UITextInput.text(in:)。 - 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
3. 把 IME 组合文本和内联预测分成“提交”与“丢弃”两条路径
做了什么:setMarkedText 先保存一次组合开始快照。它把输入法文本作为临时文档编辑。光标在标记文本开头时,它把该文本标为预测。finalizeMarkedText 会提交组合文本,却删除预测幽灵文本。
为什么这样做:预测文本不属于用户内容。若焦点变化时提交预测文本,键盘稍后的接受替换会重复单词。组合文本则必须保留,并成为单独的撤销步骤。源码还为内容通知和选择通知分开建立两个事务边界。
关键代码摘录
if markedRange == nil {
inputBackend.setCompositionSnapshot(
RichTextCompositionSnapshot(blocks: currentBlocks(), anchor: anchor, head: head))
}
inputBackend.notifyingContentChange {
applyCaretOutcome(applyReplaceOutcome(globalFrom: lo, globalTo: hi, text: text))
bumpDocumentRevision()
}
let newLen = (text as NSString).length
if newLen == 0 {
inputBackend.setCompositionMarkedRange(nil, isPrediction: false)
inputBackend.setCompositionSnapshot(nil)
} else {
inputBackend.setCompositionMarkedRange(
NSRange(location: lo, length: newLen),
isPrediction: selectedRange.location == 0 && selectedRange.length == 0)
}
refreshPredictionStyling()
inputBackend.notifyingSelectionChangeIgnoringCoalescing {
let selStart = clampGlobal(lo + selectedRange.location)
applyCaretOutcome(.range(selStart, clampGlobal(selStart + selectedRange.length)))
}代价与风险:这依赖 UITextInput 的精细调用顺序。错误的通知数量会破坏输入法、撤销或宿主滚动。代码只允许顶层正文段落使用该组合路径。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 报告摘要
- 怎么做到:
setMarkedText标注isPrediction,finalize删除幽灵。 - 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
4. 用不可见的 1×1 NSTextAttachment 预留空间,再覆盖真实动态表情视图
做了什么:附件自身没有可见内容。共享的透明 spacerImage 强制 TextKit 调用 attachmentBounds。它按 run 字体的 ascender 和 descender 预留正方形字框。随后画布在该框上安装实际动画表情视图。
为什么这样做:空附件可能被 TextKit 视为零宽。直接把动画内容栅格化进附件不能持续播放。此做法让 TextKit 负责断行和基线,真实视图负责动态像素。
关键代码摘录
final class EmojiTextAttachment: NSTextAttachment {
let ref: EmojiRef
let scale: CGFloat
let renderBoost: CGFloat
private static let spacerImage: UIImage =
UIGraphicsImageRenderer(size: CGSize(width: 1, height: 1)).image { _ in }
init(ref: EmojiRef, scale: CGFloat, renderBoost: CGFloat = 0) {
self.ref = ref
self.scale = scale
self.renderBoost = renderBoost
super.init(data: nil, ofType: nil)
self.image = Self.spacerImage
}
private func box(for font: UIFont?) -> CGRect {
let f = font ?? UIFont.preferredFont(forTextStyle: .body)
let side = (f.ascender - f.descender) * scale
return CGRect(x: 0, y: f.descender, width: side, height: side)
}代价与风险:必须同时覆写 TextKit 1 和 TextKit 2 的 attachmentBounds。附件字框与可见视图的尺寸是两套语义。后者可通过 renderBoost 溢出行距。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 2 处源码位置。
- 报告摘要
- 怎么做到:用行字体 ascender/descender 计算方框。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
5. 旧文本节点用 CTRunDelegate 把自定义 emoji 伪装成一个排版字形
做了什么:代码把一个可能多 UTF-16 单元的自定义 emoji 替换为 &。它为这个替身绑定 CTRunDelegate。CoreText 因此得到指定宽度、ascent 和 descent。TextNode 跳过该 run 的字形绘制,并在同一几何位置叠加 InlineStickerItemLayer。
为什么这样做:CoreText 原生不能排版和播放 Telegram 动画表情。替换为一个可测量的占位字形,使换行、截断和 RTL 仍由 CoreText 决定。原文本还被保存到 originalTextAttributeKey,可保留语义。
关键代码摘录
let updatedSubstring = NSMutableAttributedString(string: "&")
let replacementRange = NSRange(location: 0, length: updatedSubstring.length)
updatedSubstring.addAttributes(string.attributes(at: range.location, effectiveRange: nil),
range: replacementRange)
updatedSubstring.addAttribute(NSAttributedString.Key("Attribute__EmbeddedItem"),
value: InlineStickerItem(emoji: value, file: value.file,
fontSize: font.pointSize, enableAnimation: value.enableAnimation),
range: replacementRange)
let itemSize = (font.pointSize * 24.0 / 17.0)
let runDelegateData = RunDelegateData(ascent: font.ascender,
descent: font.descender, width: itemSize)
var callbacks = CTRunDelegateCallbacks(
version: kCTRunDelegateCurrentVersion,
dealloc: { dataRef in Unmanaged<RunDelegateData>.fromOpaque(dataRef).release() },
getAscent: { dataRef in
Unmanaged<RunDelegateData>.fromOpaque(dataRef).takeUnretainedValue().ascent
},
getDescent: { dataRef in
Unmanaged<RunDelegateData>.fromOpaque(dataRef).takeUnretainedValue().descent,
getWidth: { dataRef in
Unmanaged<RunDelegateData>.fromOpaque(dataRef).takeUnretainedValue().width
})代价与风险:需要手写 Unmanaged 生命周期。占位字符和原字符串长度不同。调用者不能把 CoreText range 当作原始自定义 emoji 的字符 range。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 报告摘要
- 怎么做到:替换子串并绘制时跳过该 run,叠加自定义 layer。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
6. 异步绘制用原子 sentinel 取消旧帧,并在主线程原子回填结果
做了什么:每次异步显示递增 _displaySentinel。后台 block 捕获当时值。新的失效请求使旧 block 的检查失败。完成 block 只在未取消时设置 layer.contents。异步任务加入父层事务,事务在主线程同步回填。
为什么这样做:文本列表在滚动和复用时会快速改变。队列中陈旧的 bitmap 不能覆盖新内容。仅靠 GCD 取消不够,因为正在执行的绘制不能可靠取消。
关键代码摘录
asdisplaynode_iscancelled_block_t isCancelledBlock = nil;
if (asynchronously) {
uint displaySentinelValue = ++_displaySentinel;
__weak ASDisplayNode *weakSelf = self;
isCancelledBlock = ^BOOL{
__strong ASDisplayNode *self = weakSelf;
return self == nil || (displaySentinelValue != self->_displaySentinel.load());
};
} else {
isCancelledBlock = ^BOOL{ return NO; };
}
asyncdisplaykit_async_transaction_operation_block_t displayBlock =
[self _displayBlockWithAsynchronous:asynchronously
isCancelledBlock:isCancelledBlock rasterizing:NO];
if (!displayBlock) { return; }代价与风险:所有绘制实现必须频繁检查 isCancelled。若绘制代码不检查,CPU 仍会浪费。节点和 layer 的主线程亲和约束很严格。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
7. 用“基础层清除 + 剪裁重绘层”实现 spoiler 文本的双层渲染
做了什么:普通文本层先绘制整行,再在 spoiler 的逐词矩形上 clear。启用 displaySpoilers 的第二层则只剪裁并绘制这些矩形中的字形。两个层可被特效节点分别控制。
为什么这样做:直接修改原 attributed string 的前景色会影响排版、命中测试或缓存。双层 bitmap 保持同一份 CTLine 几何。逐词矩形让特效贴合文字,而非覆盖整个行框。
关键代码摘录
if layout.displaySpoilers && !line.spoilers.isEmpty {
context.saveGState()
var clipRects: [CGRect] = []
for spoiler in line.spoilerWords {
var rect = spoiler.frame.offsetBy(dx: lineFrame.minX,
dy: lineFrame.minY - UIScreenPixel)
rect.size.height += 1.0 + UIScreenPixel
clipRects.append(rect)
}
context.clip(to: clipRects)
}
let glyphRuns = CTLineGetGlyphRuns(line.line) as NSArray
for run in glyphRuns {
let run = run as! CTRun
// ...
CTRunDraw(run, context, CFRangeMake(0, glyphCount))
}
// ...
if !line.spoilers.isEmpty {
if layout.displaySpoilers { context.restoreGState() }
else { clearRects.append(contentsOf: line.spoilerWords.map { $0.frame }) }
}代价与风险:渲染两层会增加绘制与内存成本。clearRects 的坐标和像素对齐错误会露出字边。该机制只适合离屏图层渲染。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 2 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
8. TextKit 2 刻意使用 .selection 几何来修正 RTL 光标冻结
做了什么:光标位置查询不使用 TextKit 2 的 .standard segment。它使用 .selection segment。空 RTL 段落还手动把光标放到容器右端。
为什么这样做:源码指出 .standard 在 RTL 中报告未右对齐的坐标。换行末尾的光标会停在旧位置。.selection 提供与选区一致的真实字形几何。
关键代码摘录
func caretRect(atOffset offset: Int) -> CGRect {
if length == 0, emptyTextCaretDirection == .rightToLeft {
return CGRect(x: container.size.width - 2, y: 0, width: 2, height: 20)
}
guard let range = textRange(offset, offset) else {
return CGRect(x: 0, y: 0, width: 2, height: 20)
}
var rect = CGRect(x: 0, y: 0, width: 2, height: 20)
layoutManager.enumerateTextSegments(in: range, type: .selection, options: []) {
_, frame, _, _ in
rect = CGRect(x: frame.minX, y: frame.minY, width: 2,
height: max(frame.height, 20))
return false
}
return rect
}代价与风险:这依赖 TextKit 2 的 segment 行为。每个光标查询都枚举 layout segment。长段落中的高频拖动可能有成本。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
9. 关闭 UITextView 滚动时仍让它认为滚动已开启
做了什么:ASPanningOverriddenUITextView 对外报告“不可滚动”,却总是把父类 scrollEnabled 设为 YES。它阻断自身 pan、contentSize 和 contentOffset 更新。
为什么这样做:源码引用 rdar://14729288。UITextView.scrollEnabled = NO 时不计算 contentSize。外层滚动容器需要该尺寸进行布局。该类保留 TextKit 的滚动布局路径,却把实际滚动手势交给父容器。
关键代码摘录
- (BOOL)scrollEnabled { return _shouldBlockPanGesture; }
- (void)setScrollEnabled:(BOOL)scrollEnabled
{
_shouldBlockPanGesture = !scrollEnabled;
self.scrollsToTop = scrollEnabled;
[super setScrollEnabled:YES];
}
- (void)setContentSize:(CGSize)contentSize {
if (_shouldBlockPanGesture) { return; }
[super setContentSize:contentSize];
}
- (void)setContentOffset:(CGPoint)contentOffset {
if (_shouldBlockPanGesture) { return; }
[super setContentOffset:contentOffset];
}
- (void)setContentOffset:(CGPoint)contentOffset animated:(BOOL)animated {
if (_shouldBlockPanGesture) { return; }
[super setContentOffset:contentOffset animated:animated];
}代价与风险:它覆写了 UIScrollView 的多个核心属性。与系统自动滚动、键盘避让或未来 TextKit 行为可能冲突。该路径仅限编辑文本节点。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
10. 自定义截断 token 采用两次 CTLineCreateTruncatedLine,防止 token 被吞掉
做了什么:TextNode 先测量 token 宽度。它首次按全宽请求 CoreText 截断。它检查生成 run 的源 range 是否已抵达原字符串末尾。若没有 token,它把可用宽度减去 token 宽度再截断一次。显式换行截断则把 token 保存为 additionalTrailingLine。
为什么这样做:自定义 token 可能带文字、图标或操作提示。一次 CoreText 截断无法保证 token 出现。默认 UILabel 的截断也不能在断行处额外绘制一条有独立样式的尾随内容。
关键代码摘录
let truncationToken = CTLineCreateWithAttributedString(truncatedTokenString)
let truncationTokenWidth = CTLineGetTypographicBounds(truncationToken, nil, nil, nil)
- CTLineGetTrailingWhitespaceWidth(truncationToken)
var effectiveLineRange = brokenLineRange
var measureFitWidth = CTLineGetTypographicBounds(originalLine, nil, nil, nil)
- CTLineGetTrailingWhitespaceWidth(originalLine)
if customTruncationToken != nil && lineRange.location + lineRange.length < attributedString.length {
measureFitWidth += truncationTokenWidth
}
// ...
let coreTextLine1 = CTLineCreateTruncatedLine(originalLine,
max(1.0, Double(lineConstrainedSize.width)), truncationType, truncationToken)
?? truncationToken
let runs = (CTLineGetGlyphRuns(coreTextLine1) as [AnyObject]) as! [CTRun]
var hasTruncationToken = false
for run in runs {
let runRange = CTRunGetStringRange(run)
if runRange.location + runRange.length >= nsString.length {
hasTruncationToken = true
break
}
}代价与风险:代码依赖 CoreText run 的 source range 解释 token 是否存在。复杂双向文本和附件使该判断的维护难度较高。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
11. 手写 CoreText 逐行“切口”排版,给头像或气泡角留出空间
做了什么:布局器对每一行判断其垂直范围是否碰到左上或右上切口。命中的行缩窄可用宽度。左上切口还平移行 x。底部切口在布局完成后再决定扩宽最后一行还是增加高度。
为什么这样做:UILabel 和标准 NSTextContainer 不易表达这种只影响前几行的气泡缺口。CoreText typesetter 允许每行使用不同宽度,并保留准确的字符和命中几何。
关键代码摘录
if let topLeft = cutout?.topLeft {
cutoutMinY = -fontLineSpacing
cutoutMaxY = topLeft.height + fontLineSpacing
cutoutWidth = topLeft.width
cutoutOffset = cutoutWidth
cutoutEnabled = true
} else if let topRight = cutout?.topRight {
cutoutMinY = -fontLineSpacing
cutoutMaxY = topRight.height + fontLineSpacing
cutoutWidth = topRight.width
cutoutEnabled = true
}
// ...
if cutoutEnabled {
if lineOriginY - fontLineHeight < cutoutMaxY &&
lineOriginY + fontLineHeight > cutoutMinY {
lineConstrainedWidth = max(1.0, lineConstrainedWidth - cutoutWidth)
lineConstrainedWidthDelta = -cutoutWidth
lineCutoutOffset = cutoutOffset
lineAdditionalWidth = cutoutWidth
}
}
let lineCharacterCount = CTTypesetterSuggestLineBreak(
typesetter, lastLineCharacterIndex, Double(lineConstrainedWidth))代价与风险:该逻辑要与自然对齐、RTL、截断和属性矩形共同维护。切口与字体行高相交的边界需要像素级测试。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
12. layer-backed 节点用弱代理修复 CALayer.delegate 悬空指针
做了什么:layer-backed 节点不把自身直接交给 CALayer.delegate。它创建弱目标代理,并用 associated object 强持有代理。layer 比 node 活得更久时,代理把回调安全地吞掉。
为什么这样做:源码明确指出 CALayer.delegate 文档称 weak,但实现表现为 assign。直接赋值时,后台析构或外部持有 layer 会留下野指针。
关键代码摘录
if (_flags.layerBacked) {
_layer = [self _locked_layerToLoad];
static int ASLayerDelegateAssociationKey;
/**
* CALayer's .delegate property is documented to be weak,
* but the implementation is actually assign.
*/
ASWeakProxy *instance = [ASWeakProxy weakProxyWithTarget:self];
_layer.delegate = (id<CALayerDelegate>)instance;
objc_setAssociatedObject(_layer, &ASLayerDelegateAssociationKey, instance,
OBJC_ASSOCIATION_RETAIN_NONATOMIC);
} else {
_view = [self _locked_viewToLoad];
_view.asyncdisplaykit_node = self;
_layer = _view.layer;
}
_layer.asyncdisplaykit_node = self;
self._locked_asyncLayer.asyncDelegate = self;代价与风险:代理可能导致回调静默丢失。关联对象增加了生命周期隐性关系。实现基于系统实际行为,而非仅靠公开文档。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
13. 以字符串拆分引用私有自动纠错提示类,并禁用私有替换菜单项
做了什么:编辑文本视图阻止 _promptForReplace: 和部分格式菜单。点击自动纠错提示时,它调用 dropAutocorrection。该方法先 unmarkText,再把选择区前移一个字符又还原,以刷新 UIKit 状态。
为什么这样做:自定义编辑器不接受系统替换提示的默认流程。它需要关闭不兼容的菜单和预测状态,避免同一内容由 UIKit 和节点层同时修改。
关键代码摘录
if (action == @selector(paste:)) {
NSArray *items = [UIMenuController sharedMenuController].menuItems;
if (((UIMenuItem *)items.firstObject).action == @selector(toggleBoldface:)) {
return false;
}
return true;
}
static SEL promptForReplaceSelector;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
promptForReplaceSelector = NSSelectorFromString(@"_promptForReplace:");
});
if (action == promptForReplaceSelector) { return false; }
if (action == @selector(toggleUnderline:)) { return false; }
return [super canPerformAction:action withSender:sender];
// ...
promptClass = NSClassFromString([[NSString alloc]
initWithFormat:@"%@AutocorrectInlinePrompt", @"UI"]);
if (result != nil && [result class] == promptClass) {
[self dropAutocorrection];
}私有 API 解混淆:真实私有 selector 是 _promptForReplace:。它直接由 NSSelectorFromString(@"_promptForReplace:") 确认。真实私有类名是 UIAutocorrectInlinePrompt。它由格式串 @"%@AutocorrectInlinePrompt" 与参数 @"UI" 拼接确认。
代价与风险:存在私有 API 名字。虽然代码只比较 selector 和运行时类,没有发送私有 selector,但静态审查与系统改名仍有风险。此项有审核风险。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 2 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
14. 复用链接高亮轮廓作为 mask,实现只在文字形状中流动的 shimmer
做了什么:TextLoadingEffectView 把 LinkHighlightingNode 生成的跨行圆角轮廓设为 UIView mask。它移动一张窄的模板渐变图片。内容层和描边层各有一张渐变图。
为什么这样做:它不必每帧重绘文本或每行创建渐变 layer。轮廓只在文本 range 或 rect 改变时更新。Core Animation 仅改变 position.x。
关键代码摘录
self.isUserInteractionEnabled = false
self.maskContentsView.mask = self.maskHighlightNode.view
self.maskContentsView.addSubview(self.backgroundView)
self.addSubview(self.maskContentsView)
self.maskBorderContentsView.mask = self.maskBorderHighlightNode.view
self.maskBorderContentsView.addSubview(self.borderBackgroundView)
self.addSubview(self.maskBorderContentsView)
// ...
private func updateAnimations(size: CGSize) {
if self.backgroundView.layer.animation(forKey: "shimmer") != nil { return }
let animation = self.backgroundView.layer.makeAnimation(
from: 0.0 as NSNumber,
to: (size.width + self.gradientWidth) as NSNumber,
keyPath: "position.x", timingFunction: CAMediaTimingFunctionName.easeOut.rawValue,
duration: self.duration, delay: 0.0, mediaTimingFunction: nil,
removeOnCompletion: true, additive: true)
animation.repeatCount = Float.infinity
self.backgroundView.layer.add(animation, forKey: "shimmer")
self.borderBackgroundView.layer.add(animation, forKey: "shimmer")
}代价与风险:UIView mask 可能触发离屏合成。长文本和多个效果实例会增加 GPU 带宽。动画只在 hierarchy 进入时启动。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 2 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
15. RichText spoiler 用显示属性隐藏字形,并以稳定身份池化粒子层
做了什么:画布按选择区决定 spoiler 是否显示。BlockLayout 用 TextKit 2 的 rendering attribute 把隐藏字形绘制为透明,而不改 storage。粒子视图的 key 由布局对象身份和段内 run 索引组成,不用易变的全局字符偏移。
为什么这样做:透明显示属性保留原有换行和文本几何。编辑 spoiler 上方文字不会重建粒子视图。无 spoiler 的光标移动走 O(1) 快路径。相同隐藏范围不增加 renderVersion,避免无意义重绘。
关键代码摘录
func syncSpoilers() {
guard documentHasSpoilers || !spoilerRuns.isEmpty || !spoilerDustViews.isEmpty else { return }
let runs = computeSpoilerRuns()
spoilerRuns = runs
let hideChanged = applySpoilerHiding(runs)
reconcileSpoilerDust(runs)
if hideChanged { setNeedsLayout() }
}
// ...
func setSpoilerHidden(_ ranges: [NSRange]) -> Bool {
let effective = ranges.filter { $0.length > 0 }
if effective == spoilerLocalRanges { return false }
for prev in spoilerRanges {
layoutManager.removeRenderingAttribute(.foregroundColor, for: prev)
}
spoilerRanges = []
renderVersion &+= 1
for r in effective {
guard let tr = textRange(r.location, r.location + r.length) else { continue }
layoutManager.addRenderingAttribute(.foregroundColor, value: UIColor.clear, for: tr)
spoilerRanges.append(tr)
}
spoilerLocalRanges = effective
return true
}代价与风险:此隐藏机制只在 TextKit 2 路径生效。BlockLayoutTK1 明确只记录范围,不重着色。选区和渲染状态的耦合增加了重绘时序复杂度。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 2 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
16. Markdown 粘贴拆成两个 run-loop 撤销组,并抑制中间闪屏
做了什么:当宿主把纯文本 Markdown 转为富文本文档时,编辑器先粘贴纯文本。下一次主 run-loop 再替换为富文本。第一阶段不通知宿主,因此用户看不到中间纯文本。
为什么这样做:UndoManager.groupsByEvent 会把同一事件中的撤销注册合并。分到下一事件后,第一次撤销从富文本回退到原 Markdown。第二次撤销才删除粘贴内容。
关键代码摘录
func pasteMarkdownTwoStep(plainText: String, rich: Document) {
guard !rich.blocks.isEmpty else { return }
let plain = plainTextFragment(plainText)
guard !plain.blocks.isEmpty else { return }
var range = (start: 0, end: 0)
suppressHostChangeNotification = true
editing {
range = spliceFragmentInEditing(plain)
return .unchanged
}
suppressHostChangeNotification = false
DispatchQueue.main.async { [weak self] in
guard let self else { return }
let size = self.documentSize
let lo = max(0, min(range.start, size))
let hi = max(lo, min(range.end, size))
guard lo < hi else { return }
self.replaceRange(globalFrom: lo, globalTo: hi, with: rich)
}
}代价与风险:异步第二步前,文档可能被其他操作改变。代码用范围和当前 documentSize 重新夹紧,但不能完全保留语义。快速编辑期间需要谨慎处理竞态。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
17. 链接高亮把相邻行矩形缝成一个连续的圆角轮廓
做了什么:高亮绘制器先分离不相邻矩形。它把近似相同的左右边吸附。然后沿多行矩形的外周构造一个 CGPath,并在行宽变化处加入连接圆角。
为什么这样做:每行单独绘制圆角矩形会在换行处留下缝隙。直接连成多边形会在不连续行之间生成斜桥。此算法同时避免两类视觉错误。
关键代码摘录
var combinedRects: [[CGRect]] = []
var currentRects: [CGRect] = []
for rect in rects {
if rect.width.isZero {
if !currentRects.isEmpty { combinedRects.append(currentRects) }
currentRects.removeAll()
} else {
if let last = currentRects.last,
!last.insetBy(dx: -1.0, dy: -1.0).intersects(rect) {
combinedRects.append(currentRects)
currentRects.removeAll()
}
currentRects.append(rect)
}
}
if !currentRects.isEmpty { combinedRects.append(currentRects) }
for rects in combinedRects {
var rects = rects.map {
$0.insetBy(dx: -inset, dy: -inset).offsetBy(dx: -topLeft.x, dy: -topLeft.y)
}
let minRadius: CGFloat = 2.0
// ... snap near-equal left and right edges before stitching the path.
}代价与风险:最坏情况下吸附循环为 rects.count * rects.count。多行长链接会增加 CPU。边界使用 1pt 容差,缩放变化时需回归测试。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
18. 文本布局比较所有影响条件,命中后直接复用旧 TextNodeLayout
做了什么:asyncLayout 捕获当前 layout。它检查尺寸、最大行数、截断方式、切口、对齐、行距、背景色和 attributed string。完全相同时直接返回旧 layout。否则计算新布局,并只在变化时 setNeedsDisplay。
为什么这样做:CTTypesetter、属性遍历和命中矩形计算在消息列表中很昂贵。布局对象还保存各行、附件和 spoiler 矩形,因此复用避免了重复计算整条几何链。
关键代码摘录
let existingLayout: TextNodeLayout? = maybeNode?.cachedLayout
return { arguments in
let layout: TextNodeLayout
var updated = false
if let existingLayout = existingLayout,
existingLayout.constrainedSize == arguments.constrainedSize,
existingLayout.maximumNumberOfLines == arguments.maximumNumberOfLines,
existingLayout.truncationType == arguments.truncationType,
existingLayout.cutout == arguments.cutout,
existingLayout.explicitAlignment == arguments.alignment,
existingLayout.lineSpacing.isEqual(to: arguments.lineSpacing) {
// ... compare background color and attributed string.
if stringMatch { layout = existingLayout }
else {
layout = TextNode.calculateLayout(attributedString: arguments.attributedString,
minimumNumberOfLines: arguments.minimumNumberOfLines,
maximumNumberOfLines: arguments.maximumNumberOfLines,
truncationType: arguments.truncationType,
backgroundColor: arguments.backgroundColor,
constrainedSize: arguments.constrainedSize, alignment: arguments.alignment)
updated = true
}
// ...代价与风险:新增影响布局的参数时,必须补入相等性判断。遗漏会造成陈旧 bitmap 或错误命中框。NSAttributedString.isEqual(to:) 对长文本也有成本。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
19. 将可 tint 的字和不可 tint 的彩色 emoji 分离为两个重叠文本节点
做了什么:每个状态保留两份同样的文本布局。一份只画普通文字。另一份只画彩色 emoji。它们重叠放置。updateTintColor 只改变文字层的 layer tint。
为什么这样做:layerTintColor 可高效地对文字状态做插值和变色。彩色 emoji 不能跟着被统一染色。TextNode 的 renderContentTypes 在 run 级过滤两类内容,保持位置一致。
关键代码摘录
private final class MultiScaleTextStateNode: ASDisplayNode {
let tintTextNode: ImmediateTextNode
let noTintTextNode: ImmediateTextNode
override init() {
self.tintTextNode = ImmediateTextNode()
self.tintTextNode.displaysAsynchronously = false
self.tintTextNode.renderContentTypes =
TextNode.RenderContentTypes.all.subtracting(TextNode.RenderContentTypes.emoji)
self.noTintTextNode = ImmediateTextNode()
self.noTintTextNode.displaysAsynchronously = false
self.noTintTextNode.renderContentTypes = .emoji
super.init()
self.addSubnode(self.tintTextNode)
self.addSubnode(self.noTintTextNode)
}
}代价与风险:文本被排版和绘制两次。该组件禁用了异步显示,适合短文本状态切换,不适合长段落。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
20. AsyncDisplayKit 的 TextKit 分叉用自定义容器保留引文缺口,并跳过自定义 emoji 字形
做了什么:分叉实现 ASCustomTextContainer,将引文首行缩短 100pt。它还实现 ASCustomLayoutManager,遇到 Attribute__CustomEmoji 时不调用父类绘制该 glyph。
为什么这样做:TextKit 仍负责普通编辑文本的换行。自定义容器在不重写整套排版的前提下留出引文图标空间。跳过 emoji glyph 可让外部动态内容覆盖其位置,避免静态字形与动画同时出现。
关键代码摘录
- (BOOL)isSimpleRectangularTextContainer { return false; }
- (CGRect)lineFragmentRectForProposedRect:(CGRect)proposedRect
atIndex:(NSUInteger)characterIndex
writingDirection:(NSWritingDirection)baseWritingDirection
remainingRect:(nullable CGRect *)remainingRect
{
CGRect result = [super lineFragmentRectForProposedRect:proposedRect
atIndex:characterIndex writingDirection:baseWritingDirection remainingRect:remainingRect];
NSTextStorage *textStorage = self.layoutManager.textStorage;
// ... inspect `Attribute__Blockquote` and whether this is its first line.
if (isFirstLine) { result.size.width -= 100.0f; }
return result;
}
- (void)showCGGlyphs:(const CGGlyph *)glyphs positions:(const CGPoint *)positions
count:(NSUInteger)glyphCount font:(UIFont *)font matrix:(CGAffineTransform)textMatrix
attributes:(NSDictionary<NSAttributedStringKey,id> *)attributes inContext:(CGContextRef)graphicsContext
{
for (NSUInteger i = 0; i < glyphCount; i++) {
if (attributes[@"Attribute__CustomEmoji"] != nil) { continue; }
[super showCGGlyphs:&glyphs[i] positions:&positions[i] count:1 font:font
matrix:textMatrix attributes:attributes inContext:graphicsContext];
}
}代价与风险:100pt 是硬编码几何。引文字体、屏幕宽度或图标布局改变时可能留白过大。自定义 layout manager 依赖 TextKit 的 glyph 回调顺序。
- 源码分析
- 已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
- 二进制验证
- 未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。
附注
额外核验说明
ASDisplayNode默认启用异步显示。它把drawParametersForAsyncLayer:的结果在主线程取出,再交给后台绘制。位置:Telegram-iOS:submodules/AsyncDisplayKit/Source/ASDisplayNode+AsyncDisplay.mm#L39-L49、#L181-L192。ASDisplayNode仅在未加载前允许切换layerBacked。它也禁止 view-backed 子节点加入 layer-backed 父节点。位置:Telegram-iOS:submodules/AsyncDisplayKit/Source/ASDisplayNode.mm#L756-L784、#L2065-L2068。- 富文本自绘光标实现了公开协议
UITextCursorView。它为 iOS 17+ 的UITextLoupeSession提供插入点视图。位置:Telegram-iOS:submodules/TelegramUI/Components/RichTextEditor/Sources/RichTextEditorUIKit/Canvas/CaretView.swift#L78-L93。 - 本次未对上游 Texture 做历史差异比对。本文“分叉”结论只依据当前仓库中的实际源码。