refactor(reader): 重构高亮选区绘制架构,对齐 WXRead 实现方案

- 移除 RDEPUBSelectableTextView,改用原生 UITextView
- 新增 NSAttributedString 自定义属性(com.rdreader.highlight/underline)注入高亮
- RDEPUBTextPageRenderView 统一绘制高亮背景、文字和选区
- RDEPUBTextSelectionController 精简,选区矩形传递给 RenderView 绘制
- 新增高亮选区复刻 WXRead 实现方案文档
- UI 测试适配新架构

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
shenlei
2026-06-01 20:57:27 +08:00
co-authored by Claude Opus 4.7
parent 948004eed1
commit 70133e4e6e
13 changed files with 801 additions and 510 deletions
@@ -0,0 +1,365 @@
# 将高亮选区实现 1:1 复刻为 WXRead 架构
## Context
当前 ReadViewSDK 的高亮选区实现与 WXRead 存在根本性架构差异。需要将选区系统从"UITextView 透明代理 + 独立 overlay 层"迁移到 WXRead 的"自定义手势 + CoreText 直接命中测试 + 统一 drawRect 绘制"架构。
## 核心差异对比
| 维度 | 当前 ReadViewSDK | WXRead |
|------|-----------------|--------|
| 选择触发 | UITextView 原生长按(透明文本) | 自定义 long-press(0.5s) + pan 手势 |
| 命中测试 | DTCoreText `stringIndex(forPosition:)` | `CTLineGetStringIndexForPosition` + 坐标翻转 |
| 选区绘制 | 独立 `RDEPUBSelectionOverlayView` overlay 层 | 同一 `drawRect:` 内绘制(文字+高亮+选区) |
| 高亮绘制 | overlay 层计算 rect 后 CG 填充 | `com.weread.highlight` 自定义属性注入 NSAttributedString,在 `drawInContext:` 中读取绘制 |
| 菜单系统 | 自定义 `selectionActionBar` UIStackView | `UIMenuController` + 自定义 items |
| 手势模型 | 无 pan 手势(UITextView 自带拖拽) | long-press 启动 + pan 扩展,`isSelecting` 控制 pan 启停 |
| 视图层级 | 3 层 overlaybackground + text + foreground | 单一 WRPageView 统一绘制 |
---
## Phase 1: 移除 UITextView,改用自定义手势 + CoreText 命中测试
### 1.1 修改 `RDEPUBPageInteractionController.swift`
当前已正确封装 DTCoreText 的 `stringIndex(forPosition:)``offset(forStringIndex:)`,算法与 WXRead 一致。
**新增方法:**
- `characterIndexForViewPoint(at viewPoint: CGPoint, in view: UIView)` — 将 UIKit 坐标转为相对于 content view 的坐标后调用 `characterIndex(at:)`,对应 WXRead 的 `stringIndexForPoint:` + `WRSFlipPointForCoreText`
> 注意:DTCoreText 已在内部处理了 UIKit↔CoreText 坐标翻转(`line.baselineOrigin` 是 UIKit 坐标),所以不需要手动翻转 Y 轴。但 WXRead 的自定义 DTCoreText 需要手动翻转。当前项目用的是原版 DTCoreText pod,行为已正确。
### 1.2 重构 `RDEPUBTextSelectionController.swift`
**当前状态:** 遵循 `UITextViewDelegate`,通过 `textViewDidChangeSelection` 接收选区变化。`handleLongPress` 方法存在但未被任何手势调用。
**改为:**
- 移除 `UITextViewDelegate` 遵循
- 移除 `textViewDidChangeSelection(_:)``textViewDidChangeSelection(_:, page:)`
- 新增状态属性:`selectionStartIndex: Int = NSNotFound``selectionEndIndex: Int = NSNotFound``isSelecting: Bool = false`
- 重构 `handleLongPress`
- `.began`:调用 `characterIndex(at:)` 设置 `selectionStartIndex = selectionEndIndex = index`,设 `isSelecting = true`
- `.ended`:设 `isSelectionFromInteraction = false`(保留,供后续扩展)
- 新增 `handlePan(_ gesture:, page:, renderView:, interactionController:)`
- `.changed`:计算字符索引,更新 `selectionEndIndex`,计算 range = `(min, max - min)`,计算 rects,更新 renderView
- 移除 `clearSelection``textView:` 参数
- `makeSelection(from:, page:)` 保持不变(已正确基于绝对偏移构建 `RDEPUBSelection`
### 1.3 重构 `RDEPUBTextContentView.swift` — 移除 UITextView
**删除:**
- `textView: RDEPUBSelectableTextView` 属性及其初始化
- `selectionProxyContent(from:)` 方法
- `textView.delegate = selectionController` 等 textView 配置代码
- `textView.frame = ...``layoutSubviews` 中的设置
- `configure(page:...)` 中所有 `textView.attributedText = ...``textView.selectedRange = ...``textView.isHidden = ...` 赋值
**新增手势识别器(对齐 WXRead 的 WRPageView):**
```swift
private let longPressGR = UILongPressGestureRecognizer(target: ..., action: #selector(handleLongPress))
private let panGR = UIPanGestureRecognizer(target: ..., action: #selector(handlePan))
private let tapGR = UITapGestureRecognizer(target: ..., action: #selector(handleTap))
```
- `longPressGR.minimumPressDuration = 0.5`(与 WXRead 一致)
- `panGR.isEnabled = false`(初始禁用,long-press began 时启用)
- `tapGR.require(toFail: longPressGR)`(与 WXRead 一致)
- 三个手势都添加到 contentView 自身
**手势响应:**
- `handleLongPress`:转发给 `selectionController.handleLongPress`,启用 `panGR`
- `handlePan`:转发给 `selectionController.handlePan`
- `handleTap`:如果 `selectionController.isSelecting``clearSelection()`,否则转发给 delegate 做工具栏切换
**菜单改为 UIMenuController(对齐 WXRead):**
- 删除 `selectionActionBar: UIStackView` 及相关方法(`showSelectionActionBarIfNeeded``hideSelectionActionBar``updateSelectionActionBarFrame``selectionMenuButton`
-`selectionController.onSelectionChanged` 回调中,当 selection 非 nil 时调用 `showSelectionMenu(in:anchorRect:)`
- `RDEPUBTextContentView` 设为 `canBecomeFirstResponder = true`override `canPerformAction` 仅允许三个自定义 selector
- 使用 `UIMenuController.shared` 配置 "拷贝"/"高亮"/"批注" 三个 `UIMenuItem`
**调整 `clearSelection()`**
```swift
func clearSelection() {
currentSelection = nil
menuSelection = nil
panGR.isEnabled = false
selectionController.clearSelection(overlayView: overlayView, backgroundOverlayView: backgroundOverlayView)
UIMenuController.shared.setMenuVisible(false, animated: true)
}
```
### 1.4 删除 `RDEPUBSelectableTextView.swift`
该文件的功能(屏蔽系统菜单、暴露自定义 action)已被 UIMenuController 方案替代,直接删除。
### 1.5 更新 `RDEPUBReaderController+ContentDelegates.swift`
`textContentView(_:, didRequestSelectionAction:, selection:)` 中的 `contentView.clearSelection()` 调用无需改动,新的 `clearSelection()` 签名兼容。
### Phase 1 验证
- UI 测试 `ReaderAnnotationTests.testSelectionMenuCreatesHighlight` 必须通过
- 手动验证:长按选词 → 弹出 UIMenuController → 点击"高亮" → 高亮创建成功
- 手动验证:拖拽扩展选区 → 蓝色选区矩形正确绘制
- 手动验证:单击空白处 → 选区清除
---
## Phase 2: 统一绘制循环 — 高亮/选区在 draw(_:) 中绘制
### 2.1 扩展 `RDEPUBTextPageRenderView.swift`
**当前状态:** 仅调用 `layoutFrame.draw(in: context, options:)` 绘制文字。
**新增属性:**
```swift
var highlightRanges: [(range: NSRange, color: UIColor)] = [] { didSet { setNeedsDisplay() } }
var underlineRanges: [(range: NSRange, color: UIColor, style: Int)] = [] { didSet { setNeedsDisplay() } }
var selectionRects: [CGRect] = [] { didSet { setNeedsDisplay() } }
var selectionColor: UIColor = UIColor(red: 70/255, green: 140/255, blue: 1, alpha: 0.24)
```
**扩展 `draw(_:)`**
```swift
override func draw(_ rect: CGRect) {
guard let context = UIGraphicsGetCurrentContext(), let layoutFrame else { return }
context.saveGState()
// 1. WXRead drawHighlightsInContext:
drawHighlights(in: context, layoutFrame: layoutFrame)
// 2.
layoutFrame.draw(in: context, options: drawOptions)
// 3. WXRead _drawSelectionInContext:
drawSelection(in: context)
context.restoreGState()
}
```
**`drawHighlights` 算法(对齐 WXRead `WRCoreTextLayoutFrame.drawHighlightsInContext:`):**
```swift
private func drawHighlights(in context: CGContext, layoutFrame: DTCoreTextLayoutFrame) {
for (range, color) in highlightRanges {
let lines = layoutFrame.lines as! [DTCoreTextLayoutLine]
for line in lines {
let overlap = NSIntersectionRange(range, line.stringRange)
guard overlap.length > 0 else { continue }
let startX = line.offset(forStringIndex: overlap.location)
let endX = line.offset(forStringIndex: overlap.location + overlap.length)
let rect = CGRect(
x: line.baselineOrigin.x + startX,
y: line.baselineOrigin.y - line.ascent,
width: endX - startX,
height: line.ascent + line.descent
)
color.withAlphaComponent(0.35).setFill() // WXRead 使 35% alpha
context.fill(rect)
}
}
}
```
> 说明:WXRead 使用 `[color colorWithAlphaComponent:0.3]`WRPageHighlight 的预设色本身已是 35% alpha,最终效果等同。当前项目使用 0.45 alpha,需调整为 0.35 以完全对齐。
**`drawSelection` 算法(对齐 WXRead `_drawSelectionInContext:`):**
```swift
private func drawSelection(in context: CGContext) {
guard !selectionRects.isEmpty else { return }
selectionColor.setFill()
for rect in selectionRects {
context.fill(rect)
}
}
```
### 2.2 简化 `RDEPUBTextContentView.swift` 视图层级
**删除/保留:**
- 删除 `backgroundOverlayView` 属性(高亮背景现在由 renderView 在文字下方绘制)
- 保留 `overlayView`(用于非 DTCoreText 回退路径和搜索高亮)
- `configure(page:...)` 中,将 highlight 数据传给 `coreTextContentView` 而非 overlayView
```swift
// DTCoreText
coreTextContentView.highlightRanges = highlights.compactMap { highlight -> (NSRange, UIColor)? in
guard let rangeInfo = highlight.rangeInfo,
let info = RDEPUBTextOffsetRangeInfo.decode(from: rangeInfo) else { return nil }
let absoluteRange = info.nsRange
let overlap = NSIntersectionRange(absoluteRange, pageAbsoluteRange)
guard overlap.length > 0 else { return nil }
let relativeRange = NSRange(location: overlap.location - page.pageStartOffset, length: overlap.length)
return (relativeRange, highlight.uiColor)
}
```
### 2.3 更新 `RDEPUBTextSelectionController.swift` — 直接更新 renderView
- `handleLongPress``handlePan` 现在直接更新 `renderView.selectionRects` 并调用 `renderView.setNeedsDisplay()`
- 移除 `overlayView.updateSelection(absoluteRange:, rects:)` 调用
### Phase 2 验证
- 视觉对比:高亮矩形与文字像素对齐
- 性能测试:单次 `draw(_:)` 耗时应与之前持平或更快
- 回归测试:搜索高亮仍正常显示
---
## Phase 3: 高亮属性注入 NSAttributedString(对齐 WXRead `com.weread.highlight`
### 3.1 定义自定义属性常量
```swift
// WXRead kWRHighlightAttributeName / kWRUnderlineAttributeName
let kRDEPUBHighlightAttributeName = NSAttributedString.Key("com.rdreader.highlight")
let kRDEPUBUnderlineAttributeName = NSAttributedString.Key("com.rdreader.underline")
```
### 3.2 新增 `RDEPUBChapterData.applyHighlights(to:page:highlights:)`
对齐 WXRead 的 `WRChapterData.addHighlightInRange:key:itemId:color:`
```swift
func applyHighlights(
to content: NSMutableAttributedString,
page: RDEPUBTextPage,
highlights: [RDEPUBHighlight]
) {
for highlight in highlights {
guard let rangeInfo = highlight.rangeInfo,
let info = RDEPUBTextOffsetRangeInfo.decode(from: rangeInfo) else { continue }
let absoluteRange = info.nsRange
let pageRange = NSRange(location: page.pageStartOffset, length: page.pageEndOffset - page.pageStartOffset)
let overlap = NSIntersectionRange(absoluteRange, pageRange)
guard overlap.length > 0 else { continue }
let relativeRange = NSRange(location: overlap.location - page.pageStartOffset, length: overlap.length)
switch highlight.style {
case .highlight:
content.addAttribute(kRDEPUBHighlightAttributeName, value: highlight.uiColor, range: relativeRange)
case .underline:
content.addAttribute(kRDEPUBUnderlineAttributeName, value: highlight.uiColor, range: relativeRange)
}
}
}
```
### 3.3 `RDEPUBTextPageRenderView.draw(_:)` 从属性读取高亮
替代 Phase 2 的 `highlightRanges` 属性方案,改为在 `draw(_:)` 中枚举 attributed string 的自定义属性:
```swift
private func drawHighlightsFromAttributes(in context: CGContext, attributedString: NSAttributedString) {
let fullRange = NSRange(location: 0, length: attributedString.length)
attributedString.enumerateAttribute(kRDEPUBHighlightAttributeName, in: fullRange) { value, range, _ in
guard let color = value as? UIColor else { return }
let rects = computeRects(for: range) // line + CTLineGetOffsetForStringIndex
color.withAlphaComponent(0.35).setFill()
for rect in rects { context.fill(rect) }
}
}
```
### 3.4 更新 `RDEPUBTextContentView.configure(page:...)`
```swift
// renderView
let displayContent = darkImageAdjustedContentIfNeeded(...)
chapterData.applyHighlights(to: displayContent, page: page, highlights: highlights)
coreTextContentView.attributedDisplayContent = displayContent //
```
### Phase 3 验证
- 高亮在翻页后仍正确显示(属性嵌入 attributed string,不依赖外部状态)
- 高亮颜色、alpha、rect 与 WXRead 截图一致
---
## Phase 4: 手势模型对齐 — long-press + pan + isSelecting
### 4.1 手势冲突处理
**风险:** pan 手势(选区扩展)可能与 RDReaderView 的翻页手势冲突。
**解决方案(对齐 WXRead):**
- `panGR` 初始 `isEnabled = false`,仅在 `isSelecting = true` 时启用
- `clearSelection()` 时禁用 `panGR`
- 新增 delegate 方法通知父视图:
```swift
func textContentViewDidBeginSelection(_ contentView: RDEPUBTextContentView)
func textContentViewDidEndSelection(_ contentView: RDEPUBTextContentView)
```
- `RDReaderView` 在 `didBeginSelection` 时禁用翻页手势,在 `didEndSelection` 时恢复
### 4.2 WXRead 手势时序对齐
WXRead 的手势流程:
1. long-press `.began` → 设置 `selectionStartIndex = selectionEndIndex = index``isSelecting = true`,启用 panGR`setNeedsDisplay`
2. long-press `.ended` → 无额外操作(保留选区)
3. pan `.changed` → 更新 `selectionEndIndex`,计算 rects`setNeedsDisplay`
4. single tap → `clearSelection()`,禁用 panGR
### Phase 4 验证
- 长按选词 → 拖拽扩展 → 单击取消,全流程流畅
- 无选区时翻页手势正常
- 有选区时翻页手势被禁用
---
## Phase 5: 菜单系统对齐 + 清理
### 5.1 UIMenuController 替换 selectionActionBar
已在 Phase 1 中完成。此阶段仅做清理:
- 删除 `RDEPUBSelectableTextView.swift`
- 标记 `RDEPUBSelectionOverlayView.swift` 和 `RDEPUBTextPageDecorationView.swift` 为 deprecated(保留给非 DTCoreText 回退路径)
### 5.2 更新 UI 测试
`ReaderAnnotationTests` 中查找菜单项的方式需更新:
- 当前:`app.buttons["高亮"]`UIStackView 中的按钮)
- 改为:`app.menuItems["高亮"]`UIMenuController 的菜单项)
- 或者:保留 `accessibilityIdentifier` 在 RDEPUBTextContentView 上以便测试定位
### 5.3 高亮颜色对齐
WXRead 的 5 种预设色(35% alpha):
- Yellow: `(1.0, 0.92, 0.23, 0.35)`
- Blue: `(0.26, 0.65, 0.96, 0.35)`
- Red: `(0.96, 0.26, 0.26, 0.35)`
- Green: `(0.30, 0.85, 0.39, 0.35)`
- Purple: `(0.67, 0.33, 0.97, 0.35)`
当前项目使用 CSS hex 颜色 + 0.45 alpha,需对齐为 WXRead 的 RGBA 值。
---
## 文件变更清单
| 文件 | 操作 | Phase |
|------|------|-------|
| `RDEPUBPageInteractionController.swift` | 修改:新增 `characterIndexForViewPoint` | 1 |
| `RDEPUBTextSelectionController.swift` | 重构:移除 UITextViewDelegate,新增 pan 处理、状态机 | 1 |
| `RDEPUBTextContentView.swift` | 重构:移除 textView,新增手势,替换菜单,简化层级 | 1,2,4 |
| `RDEPUBSelectableTextView.swift` | **删除** | 1 |
| `RDEPUBTextPageRenderView.swift` | 扩展:新增高亮/选区/下划线绘制逻辑 | 2,3 |
| `RDEPUBChapterData.swift` | 新增:`applyHighlights(to:page:highlights:)` | 3 |
| `RDEPUBReaderController+ContentDelegates.swift` | 小改:适配新 clearSelection 签名 | 1 |
| `RDReaderView.swift` | 新增:选区期间禁用翻页手势 | 4 |
| `RDEPUBSelectionOverlayView.swift` | 保留(deprecated for DTCoreText path | 5 |
| `RDEPUBTextPageDecorationView.swift` | 保留(deprecated for DTCoreText path | 5 |
| `RDEPUBTextAnnotationOverlay.swift` | 保留(deprecated for DTCoreText path | 5 |
| `ReaderAnnotationTests.swift` | 更新:菜单项查找方式 | 5 |
## 验证方案
1. **UI 测试**`ReaderAnnotationTests` 全部通过
2. **手动测试**
- 长按选词 → 蓝色选区高亮显示
- 拖拽扩展选区 → 选区跟随手指
- 点击"高亮" → 黄色高亮创建成功
- 翻页后返回 → 高亮仍存在
- 点击"拷贝" → 文本已复制
- 点击"批注" → 弹出笔记输入框
- 单击空白处 → 选区清除
3. **性能测试**`draw(_:)` 耗时 ≤ 之前(单次绘制 vs 三次绘制)
4. **对比验证**:与 WXRead 截图对比高亮颜色、alpha、rect 位置