Telegram 技巧档案UIKIT · APPKIT
iOS · UIKit

ASDK 与文本

AsyncDisplayKit 异步绘制、自绘选择与 IME 修复。

范围:Telegram-iOS 的 AsyncDisplayKit 分叉、CoreText 文本节点与 RichTextEditor 的文本渲染和输入层。

结论:该分叉把大量系统文本行为移到自有布局层。它用后台绘制、CoreText 行级几何、真实附件视图和自绘 UITextInput,避开 UIKit 的多处缺陷。以下按意外程度和工程价值排序。代码来自 Telegram-iOS 提交 f1dd7a2dbd02cbbf513e75d5695d8d36d1cf5838。

1. 不安装系统选择交互,完全自绘选择 UI

#05-01Telegram-iOSUIKit报告精选 05-1源码分析私有 API文本与输入手势与滚动

做了什么:DocumentCanvasView 实现 UITextInput,但不创建 UITextSelectionDisplayInteraction。它保留 UIEditMenuInteraction,并自行绘制光标、选区和两个手柄。它只在一次放大镜拖动期间创建临时交互对象。

为什么这样做:源码记录 iOS 18+/26 会留下系统默认手柄。自定义空视图无法抑制这些手柄。自绘 UI 还能使表格内的光标、手柄和选区跟随横向滚动及回弹。

关键代码摘录

DocumentCanvasView+Interaction.swift · L44–L68
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。

证据链 · #05-01iOS 未做二进制验证
源码分析
已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
报告摘要
怎么做到:选择/光标/手柄均由 DocumentCanvasView 绘制管理。
二进制验证
未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。

2. 向键盘投影“虚拟换行”,修复韩文和 CJK 跨段重组

#05-02Telegram-iOSUIKit报告精选 05-2源码分析文本与输入

做了什么:结构化文档内部使用连续的全局 UTF-16 位置。它向 UITextInput.text(in:) 投影文本时,才在跨顶层段落处插入 \n。它把 emoji 和公式附件还原为可读的替代文本。

为什么这样做:键盘会通过 text(in:) 读取上下文。没有换行时,韩文和 CJK 输入法把上下段当成同一行。源码明确记录过尾辅音迁移到上一行的错误。表格单元格不插入分隔符,因此表格仍是一个编辑面。

关键代码摘录

DocumentCanvasView+UITextInput.swift · L40–L77
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
}

代价与风险:模型位置与键盘文本位置不是同一个序列。任何新增叶子区域都必须遵守同一分隔规则。否则输入法和复制结果会分歧。

证据链 · #05-02iOS 未做二进制验证
源码分析
已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
报告摘要
怎么做到:遍历叶区间,投影到 UITextInput.text(in:)。
二进制验证
未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。

3. 把 IME 组合文本和内联预测分成“提交”与“丢弃”两条路径

#05-03Telegram-iOSUIKit报告精选 05-3源码分析文本与输入手势与滚动

做了什么:setMarkedText 先保存一次组合开始快照。它把输入法文本作为临时文档编辑。光标在标记文本开头时,它把该文本标为预测。finalizeMarkedText 会提交组合文本,却删除预测幽灵文本。

为什么这样做:预测文本不属于用户内容。若焦点变化时提交预测文本,键盘稍后的接受替换会重复单词。组合文本则必须保留,并成为单独的撤销步骤。源码还为内容通知和选择通知分开建立两个事务边界。

关键代码摘录

DocumentCanvasView+MarkedText.swift · L109–L161
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 的精细调用顺序。错误的通知数量会破坏输入法、撤销或宿主滚动。代码只允许顶层正文段落使用该组合路径。

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

4. 用不可见的 1×1 NSTextAttachment 预留空间,再覆盖真实动态表情视图

做了什么:附件自身没有可见内容。共享的透明 spacerImage 强制 TextKit 调用 attachmentBounds。它按 run 字体的 ascender 和 descender 预留正方形字框。随后画布在该框上安装实际动画表情视图。

为什么这样做:空附件可能被 TextKit 视为零宽。直接把动画内容栅格化进附件不能持续播放。此做法让 TextKit 负责断行和基线,真实视图负责动态像素。

关键代码摘录

EmojiTextAttachment.swift · L19–L62 等 2 处
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 溢出行距。

证据链 · #05-04iOS 未做二进制验证
源码分析
已在 Telegram-iOS 固定提交中逐行确认,共 2 处源码位置。
报告摘要
怎么做到:用行字体 ascender/descender 计算方框。
二进制验证
未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。

5. 旧文本节点用 CTRunDelegate 把自定义 emoji 伪装成一个排版字形

#05-05Telegram-iOSUIKit报告精选 05-5源码分析Core Animation文本与输入

做了什么:代码把一个可能多 UTF-16 单元的自定义 emoji 替换为 &。它为这个替身绑定 CTRunDelegate。CoreText 因此得到指定宽度、ascent 和 descent。TextNode 跳过该 run 的字形绘制,并在同一几何位置叠加 InlineStickerItemLayer。

为什么这样做:CoreText 原生不能排版和播放 Telegram 动画表情。替换为一个可测量的占位字形,使换行、截断和 RTL 仍由 CoreText 决定。原文本还被保存到 originalTextAttributeKey,可保留语义。

关键代码摘录

TextNodeWithEntities.swift · L176–L230
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。

证据链 · #05-05iOS 未做二进制验证
源码分析
已在 Telegram-iOS 固定提交中逐行确认,共 1 处源码位置。
报告摘要
怎么做到:替换子串并绘制时跳过该 run,叠加自定义 layer。
二进制验证
未做。App Store 版 IPA 受 FairPlay 加密,无法合法取得解密的可执行文件。

6. 异步绘制用原子 sentinel 取消旧帧,并在主线程原子回填结果

#05-06Telegram-iOSUIKit源码分析Core Animation性能与内存

做了什么:每次异步显示递增 _displaySentinel。后台 block 捕获当时值。新的失效请求使旧 block 的检查失败。完成 block 只在未取消时设置 layer.contents。异步任务加入父层事务,事务在主线程同步回填。

为什么这样做:文本列表在滚动和复用时会快速改变。队列中陈旧的 bitmap 不能覆盖新内容。仅靠 GCD 取消不够,因为正在执行的绘制不能可靠取消。

关键代码摘录

ASDisplayNode+AsyncDisplay.mm · L333–L411
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 的主线程亲和约束很严格。

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

7. 用“基础层清除 + 剪裁重绘层”实现 spoiler 文本的双层渲染

做了什么:普通文本层先绘制整行,再在 spoiler 的逐词矩形上 clear。启用 displaySpoilers 的第二层则只剪裁并绘制这些矩形中的字形。两个层可被特效节点分别控制。

为什么这样做:直接修改原 attributed string 的前景色会影响排版、命中测试或缓存。双层 bitmap 保持同一份 CTLine 几何。逐词矩形让特效贴合文字,而非覆盖整个行框。

关键代码摘录

TextNode.swift · L2567–L2640 等 2 处
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 的坐标和像素对齐错误会露出字边。该机制只适合离屏图层渲染。

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

8. TextKit 2 刻意使用 .selection 几何来修正 RTL 光标冻结

#05-08Telegram-iOSUIKit源码分析文本与输入手势与滚动

做了什么:光标位置查询不使用 TextKit 2 的 .standard segment。它使用 .selection segment。空 RTL 段落还手动把光标放到容器右端。

为什么这样做:源码指出 .standard 在 RTL 中报告未右对齐的坐标。换行末尾的光标会停在旧位置。.selection 提供与选区一致的真实字形几何。

关键代码摘录

BlockLayout.swift · L138–L157
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。长段落中的高频拖动可能有成本。

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

9. 关闭 UITextView 滚动时仍让它认为滚动已开启

#05-09Telegram-iOSUIKit源码分析文本与输入手势与滚动

做了什么:ASPanningOverriddenUITextView 对外报告“不可滚动”,却总是把父类 scrollEnabled 设为 YES。它阻断自身 pan、contentSize 和 contentOffset 更新。

为什么这样做:源码引用 rdar://14729288。UITextView.scrollEnabled = NO 时不计算 contentSize。外层滚动容器需要该尺寸进行布局。该类保留 TextKit 的滚动布局路径,却把实际滚动手势交给父容器。

关键代码摘录

ASEditableTextNode.mm · L69–L135
- (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 行为可能冲突。该路径仅限编辑文本节点。

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

10. 自定义截断 token 采用两次 CTLineCreateTruncatedLine,防止 token 被吞掉

#05-10Telegram-iOSUIKit源码分析文本与输入

做了什么:TextNode 先测量 token 宽度。它首次按全宽请求 CoreText 截断。它检查生成 run 的源 range 是否已抵达原字符串末尾。若没有 token,它把可用宽度减去 token 宽度再截断一次。显式换行截断则把 token 保存为 additionalTrailingLine。

为什么这样做:自定义 token 可能带文字、图标或操作提示。一次 CoreText 截断无法保证 token 出现。默认 UILabel 的截断也不能在断行处额外绘制一条有独立样式的尾随内容。

关键代码摘录

TextNode.swift · L1991–L2055
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 是否存在。复杂双向文本和附件使该判断的维护难度较高。

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

11. 手写 CoreText 逐行“切口”排版,给头像或气泡角留出空间

#05-11Telegram-iOSUIKit源码分析文本与输入

做了什么:布局器对每一行判断其垂直范围是否碰到左上或右上切口。命中的行缩窄可用宽度。左上切口还平移行 x。底部切口在布局完成后再决定扩宽最后一行还是增加高度。

为什么这样做:UILabel 和标准 NSTextContainer 不易表达这种只影响前几行的气泡缺口。CoreText typesetter 允许每行使用不同宽度,并保留准确的字符和命中几何。

关键代码摘录

TextNode.swift · L1803–L1855
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、截断和属性矩形共同维护。切口与字体行高相交的边界需要像素级测试。

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

12. layer-backed 节点用弱代理修复 CALayer.delegate 悬空指针

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

做了什么:layer-backed 节点不把自身直接交给 CALayer.delegate。它创建弱目标代理,并用 associated object 强持有代理。layer 比 node 活得更久时,代理把回调安全地吞掉。

为什么这样做:源码明确指出 CALayer.delegate 文档称 weak,但实现表现为 assign。直接赋值时,后台析构或外部持有 layer 会留下野指针。

关键代码摘录

ASDisplayNode.mm · L582–L609
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;

代价与风险:代理可能导致回调静默丢失。关联对象增加了生命周期隐性关系。实现基于系统实际行为,而非仅靠公开文档。

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

13. 以字符串拆分引用私有自动纠错提示类,并禁用私有替换菜单项

做了什么:编辑文本视图阻止 _promptForReplace: 和部分格式菜单。点击自动纠错提示时,它调用 dropAutocorrection。该方法先 unmarkText,再把选择区前移一个字符又还原,以刷新 UIKit 状态。

为什么这样做:自定义编辑器不接受系统替换提示的默认流程。它需要关闭不兼容的菜单和预测状态,避免同一内容由 UIKit 和节点层同时修改。

关键代码摘录

ASEditableTextNode.mm · L141–L175 等 2 处
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,但静态审查与系统改名仍有风险。此项有审核风险。

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

14. 复用链接高亮轮廓作为 mask,实现只在文字形状中流动的 shimmer

做了什么:TextLoadingEffectView 把 LinkHighlightingNode 生成的跨行圆角轮廓设为 UIView mask。它移动一张窄的模板渐变图片。内容层和描边层各有一张渐变图。

为什么这样做:它不必每帧重绘文本或每行创建渐变 layer。轮廓只在文本 range 或 rect 改变时更新。Core Animation 仅改变 position.x。

关键代码摘录

TextLoadingEffect.swift · L49–L59 等 2 处
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 进入时启动。

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

15. RichText spoiler 用显示属性隐藏字形,并以稳定身份池化粒子层

做了什么:画布按选择区决定 spoiler 是否显示。BlockLayout 用 TextKit 2 的 rendering attribute 把隐藏字形绘制为透明,而不改 storage。粒子视图的 key 由布局对象身份和段内 run 索引组成,不用易变的全局字符偏移。

为什么这样做:透明显示属性保留原有换行和文本几何。编辑 spoiler 上方文字不会重建粒子视图。无 spoiler 的光标移动走 O(1) 快路径。相同隐藏范围不增加 renderVersion,避免无意义重绘。

关键代码摘录

DocumentCanvasView+Spoilers.swift · L37–L55 等 2 处
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 明确只记录范围,不重着色。选区和渲染状态的耦合增加了重绘时序复杂度。

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

16. Markdown 粘贴拆成两个 run-loop 撤销组,并抑制中间闪屏

#05-16Telegram-iOSUIKit源码分析文本与输入性能与内存

做了什么:当宿主把纯文本 Markdown 转为富文本文档时,编辑器先粘贴纯文本。下一次主 run-loop 再替换为富文本。第一阶段不通知宿主,因此用户看不到中间纯文本。

为什么这样做:UndoManager.groupsByEvent 会把同一事件中的撤销注册合并。分到下一事件后,第一次撤销从富文本回退到原 Markdown。第二次撤销才删除粘贴内容。

关键代码摘录

DocumentCanvasView+Clipboard.swift · L198–L224
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 重新夹紧,但不能完全保留语义。快速编辑期间需要谨慎处理竞态。

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

17. 链接高亮把相邻行矩形缝成一个连续的圆角轮廓

做了什么:高亮绘制器先分离不相邻矩形。它把近似相同的左右边吸附。然后沿多行矩形的外周构造一个 CGPath,并在行宽变化处加入连接圆角。

为什么这样做:每行单独绘制圆角矩形会在换行处留下缝隙。直接连成多边形会在不连续行之间生成斜桥。此算法同时避免两类视觉错误。

关键代码摘录

LinkHighlightingNode.swift · L59–L195
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 容差,缩放变化时需回归测试。

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

18. 文本布局比较所有影响条件,命中后直接复用旧 TextNodeLayout

#05-18Telegram-iOSUIKit源码分析文本与输入

做了什么:asyncLayout 捕获当前 layout。它检查尺寸、最大行数、截断方式、切口、对齐、行距、背景色和 attributed string。完全相同时直接返回旧 layout。否则计算新布局,并只在变化时 setNeedsDisplay。

为什么这样做:CTTypesetter、属性遍历和命中矩形计算在消息列表中很昂贵。布局对象还保存各行、附件和 spoiler 矩形,因此复用避免了重复计算整条几何链。

关键代码摘录

TextNode.swift · L2854–L2906
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:) 对长文本也有成本。

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

19. 将可 tint 的字和不可 tint 的彩色 emoji 分离为两个重叠文本节点

#05-19Telegram-iOSUIKit源码分析Core Animation文本与输入性能与内存

做了什么:每个状态保留两份同样的文本布局。一份只画普通文字。另一份只画彩色 emoji。它们重叠放置。updateTintColor 只改变文字层的 layer tint。

为什么这样做:layerTintColor 可高效地对文字状态做插值和变色。彩色 emoji 不能跟着被统一染色。TextNode 的 renderContentTypes 在 run 级过滤两类内容,保持位置一致。

关键代码摘录

MultiScaleTextNode.swift · L6–L25
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)
    }
}

代价与风险:文本被排版和绘制两次。该组件禁用了异步显示,适合短文本状态切换,不适合长段落。

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

20. AsyncDisplayKit 的 TextKit 分叉用自定义容器保留引文缺口,并跳过自定义 emoji 字形

#05-20Telegram-iOSUIKit源码分析文本与输入

做了什么:分叉实现 ASCustomTextContainer,将引文首行缩短 100pt。它还实现 ASCustomLayoutManager,遇到 Attribute__CustomEmoji 时不调用父类绘制该 glyph。

为什么这样做:TextKit 仍负责普通编辑文本的换行。自定义容器在不重写整套排版的前提下留出引文图标空间。跳过 emoji glyph 可让外部动态内容覆盖其位置,避免静态字形与动画同时出现。

关键代码摘录

ASTextKitComponents.mm · L26–L80
- (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 回调顺序。

证据链 · #05-20iOS 未做二进制验证
源码分析
已在 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 做历史差异比对。本文“分叉”结论只依据当前仓库中的实际源码。

本页目录

1. 不安装系统选择交互,完全自绘选择 UI2. 向键盘投影“虚拟换行”,修复韩文和 CJK 跨段重组3. 把 IME 组合文本和内联预测分成“提交”与“丢弃”两条路径4. 用不可见的 1×1 NSTextAttachment 预留空间,再覆盖真实动态表情视图5. 旧文本节点用 CTRunDelegate 把自定义 emoji 伪装成一个排版字形6. 异步绘制用原子 sentinel 取消旧帧,并在主线程原子回填结果7. 用“基础层清除 + 剪裁重绘层”实现 spoiler 文本的双层渲染8. TextKit 2 刻意使用 .selection 几何来修正 RTL 光标冻结9. 关闭 UITextView 滚动时仍让它认为滚动已开启10. 自定义截断 token 采用两次 CTLineCreateTruncatedLine,防止 token 被吞掉11. 手写 CoreText 逐行“切口”排版,给头像或气泡角留出空间12. layer-backed 节点用弱代理修复 CALayer.delegate 悬空指针13. 以字符串拆分引用私有自动纠错提示类,并禁用私有替换菜单项14. 复用链接高亮轮廓作为 mask,实现只在文字形状中流动的 shimmer15. RichText spoiler 用显示属性隐藏字形,并以稳定身份池化粒子层16. Markdown 粘贴拆成两个 run-loop 撤销组,并抑制中间闪屏17. 链接高亮把相邻行矩形缝成一个连续的圆角轮廓18. 文本布局比较所有影响条件,命中后直接复用旧 TextNodeLayout19. 将可 tint 的字和不可 tint 的彩色 emoji 分离为两个重叠文本节点20. AsyncDisplayKit 的 TextKit 分叉用自定义容器保留引文缺口,并跳过自定义 emoji 字形附注额外核验说明