# 微信读书 EPUB 阅读器 — 符号恢复与核心算法 > 基于 WeRead v10.0.3 (Build 79) 二进制逆向 > 方法签名来源: __objc_methname 段 + binary strings > 类名来源: __objc_classname 段 (17584 个类, 其中 320 个 EPUB 相关) --- ## 一、符号恢复映射表 ### 1.1 核心类方法签名恢复 (439 个) #### WREpubTypesetter (EPUB 排版器入口) ``` 恢复前 (二进制地址) 恢复后 (符号名) 功能 ───────────────────────────────────────────────────────────────────────────────────── imp@0x100a489ac +[WREpubTypesetter attributeStringWithFilePath: XHTML→NSAttributedString priority: 核心排版方法 insertArticleToolAttachment: 5层CSS级联 insertBookChapterToolAttachment: 图片/链接处理 insertRecommendView: 繁简转换 book:chapter:pageFlippingStyle: 免费试读截断 renderErrorReason:isStyleFileNotFound:options:] imp@0x100a49b9c +[WREpubTypesetter tryReportTranslationError: 翻译错误上报 bookId:chapter: isTranslationStyleNotFound: isTranslationContentNotFound: isTranslateTagButNoTranslateStyle:] ``` #### DTHTMLAttributedStringBuilder (HTML→NSAttributedString 构建器) ``` imp@0x... -[DTHTMLAttributedStringBuilder _buildString] 核心构建方法 imp@0x... -[DTHTMLAttributedStringBuilder parser:didStartElement: SAX 标签开始 attributes:position:] imp@0x... -[DTHTMLAttributedStringBuilder parser:foundCDATA:] CDATA 处理 imp@0x... -[DTHTMLAttributedStringBuilder parser:foundCharacters: 文本节点 position:] imp@0x... -[DTHTMLAttributedStringBuilder parserDidEndDocument:] 解析完成 ``` #### DTHTMLElement (HTML 元素模型, 168 methods) ``` imp@0x... -[DTHTMLElement applyStyleDictionary: CSS 样式应用 isLatinLanguageBook:] imp@0x... -[DTHTMLElement attributedString] 生成富文本 imp@0x... -[DTHTMLElement interpretAttributes] 属性解析 ``` #### WRCoreTextLayoutFrame (单页排版帧, 49 methods) ``` imp@0x... -[WRCoreTextLayoutFrame drawInContext: 核心绘制方法 image:size:inRect:position:] CGContext 绘制 imp@0x... -[WRCoreTextLayoutFrame avoidPageBreakInside 避免断页 ByRemovingLastLinesIfNeeded] 移除最多3行 imp@0x... -[WRCoreTextLayoutFrame getRenderHeight] 获取渲染高度 imp@0x... -[WRCoreTextLayoutFrame lines] 获取行列表 ``` #### DTCoreTextGlyphRun (字形渲染) ``` imp@0x... -[DTCoreTextGlyphRun newPathWithGlyphs] CGPath 字形路径 CTFontCreatePathForGlyph ``` #### WRChapterData (章节数据模型, 125 methods) ``` imp@0x... +[WRChapterData addUnderLineToAttributedString: 添加下划线 range:itemId:style:color:] imp@0x... +[WRChapterData freeTrialChapterCutOffStringLocaion 试读截断位置 WithAttributedString:book:] imp@0x... -[WRChapterData addHighlightInRange: 添加高亮 key:itemId:color:] imp@0x... -[WRChapterData addReviewUnderlineInRange: 添加书评下划线 itemId:type:] imp@0x... -[WRChapterData rangeOfPage:] 获取页范围 imp@0x... -[WRChapterData generateOutlineContents] 生成目录 imp@0x... -[WRChapterData markFreeTrialChapterCutOffString 标记试读截断 Location:] ``` #### WRPageViewController (翻页控制器, 80 methods) ``` imp@0x... -[WRPageViewController initWithDelegate: 初始化 withPageType:pageFlippingStyle:] imp@0x... -[WRPageViewController pageViewController: 上一页 viewControllerBeforeViewController:] imp@0x... -[WRPageViewController pageViewController: 下一页 viewControllerAfterViewController:] imp@0x... +[WRPageViewController patchNavigationDirectionFault] 导航方向修复 imp@0x... +[WRPageViewController patchNoViewController 页面管理修复 ManagingPageViewFault] imp@0x... +[WRPageViewController patchUIPageCurlFault] 翻页动画修复 imp@0x... +[WRPageViewController detectNavigationDirection 崩溃检测 CrashWithPageViewController:...] ``` #### WRReaderViewController (阅读器主控制器, 431 methods) ``` imp@0x... -[WRReaderViewController initWithBook: 初始化 progress:forceUseInitialProgress: doodleMode:autoRead:] imp@0x... -[WRReaderViewController renderPageView: 渲染页面 progressData:source:] imp@0x... -[WRReaderViewController recomposeCurrentPageView 重新排版 WithSource:] imp@0x... -[WRReaderViewController gotoChapterIdx: 跳转章节 position:positionOfFile:] imp@0x... -[WRReaderViewController _saveReadingProgress 保存进度 AndIsAsync:] imp@0x... -[WRReaderViewController changeTypesetterAttributes 修改排版属性 WithBlock:] imp@0x... -[WRReaderViewController initChapterPageCount] 初始化分页 ``` #### WREncryptedFileManager (DRM 管理) ``` imp@0x... +[WREncryptedFileManager decryptContentsOfFile: AES 解密 forBookId:isFileLost:] imp@0x... +[WREncryptedFileManager encryptFileForBookId: 本地加密 originalEncryptKey:atPath:toPath:] imp@0x... +[WREncryptedFileManager keyForBookId:] 获取密钥 ``` #### WRBookNetwork (网络层, 44 class methods) ``` +[WRBookNetwork loadTarForEpubBookId:chapter:isPreload:] 下载加密 ZIP +[WRBookNetwork loadTarForEpubBookId:chapters:isPreload:] 批量下载 +[WRBookNetwork handleUnzipWithBookId:zipPath:encryptKey:...] 解密解压 +[WRBookNetwork handleUnzipErrorWithPath:plainBookDirectory:] 解压错误处理 +[WRBookNetwork processEncryptedBookFileAtPath:encryptKey:...] 文件处理 +[WRBookNetwork fileContentWithChapter:book:shouldRemoveHtmlTags: 读取内容 +:filterTranslateContent:] +[WRBookNetwork loadChapterContentWithParam:callback:] 加载章节内容 +[WRBookNetwork chaptersInfoFromFile:checkTranslate:] 章节信息解析 +[WRBookNetwork savePreloadInfoWithDownloadParam:...] 保存预加载信息 +[WRBookNetwork clearPreloadKVWithBookId:chapterUid:zipPath:] 清理预加载缓存 +[WRBookNetwork storeTarEpubImageToDiskWithBookId:...] 存储图片 +[WRBookNetwork loadBookmarkListWithBookId:syncKey:callback:] 加载书签 +[WRBookNetwork addReview:shareToWechat:audioArticleId:...] 添加书评 +[WRBookNetwork searchResultsForBook:chapterUid:searchString:...] 搜索 +[WRBookNetwork uploadBookProgressAndReadingTime:...] 上传进度 +[WRBookNetwork loadBookInfoWithBookId:source:callback:] 书籍信息 +[WRBookNetwork loadBookReadInfoWithBookId:callback:] 阅读信息 +[WRBookNetwork fetchLockInfoWithBookId:callback:] 锁定信息 +[WRBookNetwork resetChapterPaidIfNeededWithBookId:chapterUid:] 重置付费 +[WRBookNetwork markReadingStatus:bookIds:isCancel:callback:] 标记阅读状态 +[WRBookNetwork setFinishReading:withBookId:callback:] 标记完成 +[WRBookNetwork setIsStartReading:withBookId:callback:] 标记开始 +[WRBookNetwork likeReviewById:isUnlike:withParams:callback:] 点赞 +[WRBookNetwork dislikeReviewById:isDislike:withParams:callback:] 点踩 +[WRBookNetwork repostReview:reposted:callback:] 转发 +[WRBookNetwork rewardReviewForId:price:timestamp:callback:] 打赏 +[WRBookNetwork postReviewHideWithBookId:hide:callback:] 隐藏书评 +[WRBookNetwork loadReadTimeWelfareActionWithBookId:...] 阅读时长福利 +[WRBookNetwork automaticallyMarkFinishReadingWithBookId:...] 自动标记完成 +[WRBookNetwork pollingChapterTranslateStatusWithBookId:...] 轮询翻译状态 +[WRBookNetwork _removeTranslateHtml:] 移除翻译HTML +[WRBookNetwork processChapterInfosFromChapterDownload:bookId:] 处理章节信息 ``` ### 1.2 ivar 恢复表 (关键类) ``` WREpubTypesetter (4 ivars): _cssFilePath NSString 内嵌 CSS 文件路径 _replaceCSSPath NSString replace.css 路径 _darkCSSPath NSString dark.css 路径 _options NSArray 排版选项 WRChapterData (12 ivars): _attributedString NSMutableAttributedString 排版后的富文本 _pageRanges NSArray 每页 NSRange 数组 _highlights NSArray 高亮列表 _underlines NSArray 下划线列表 _reviews NSArray 书评列表 _tempHighlights NSArray 临时高亮 _tempUnderlines NSArray 临时下划线 _layouter WRCoreTextLayouter 排版器引用 _outlineContents NSArray 目录内容 _searchResults NSArray 搜索结果 _bookmarkSet NSSet 书签集合 _originalString NSAttributedString 原始富文本 WRCoreTextLayouter (32 ivars): _htmlString NSString HTML 源码 _attributedString NSAttributedString 排版后的富文本 _layoutString NSAttributedString 实际排版用的富文本 _book WRBook 书籍引用 _chapter WRChapter 章节引用 _markContentChapter WRMarkContentChapter 标注内容 ... WRCoreTextLayoutFrame (25 ivars): _attributedString NSAttributedString 排版用富文本 _lines NSMutableArray DTCoreTextLayoutLine 数组 _frame NSString CTFrame 描述 _stringIndices NSArray 字符索引映射 _pageRanges NSArray 页面范围 ... WRPageView (16 ivars): _pageElements NSArray 页面元素列表 _imageViews NSMutableArray 图片视图缓存 _autoReadTimer NSTimer 自动阅读定时器 _turnPageTimer NSTimer 翻页定时器 _chapterTitle NSString 章节标题 _loadingLabel UILabel 加载提示 _loadingImageView UIImageView 加载图片 _activityIndicator WRActivityIndicator 加载指示器 _errorLabel UILabel 错误提示 _retryButton QMUIButton 重试按钮 _loadingProgress WRLoadingProgressView 加载进度 ... DTHTMLElement (38 ivars): _textAttachment DTTextAttachment 文本附件 _linkURL NSURL 链接地址 _textColor UIColor 文字颜色 _backgroundColor UIColor 背景色 _tagName NSString 标签名 _children NSArray 子元素 _attributes NSDictionary HTML 属性 _cssStyles NSDictionary CSS 样式 _borderStyle DTBorderStyle 边框样式 _backgroundImage DTBackgroundImageStyle 背景图 _tableStyle DTTableStyle 表格样式 ... ``` --- ## 二、精简核心算法实现 (Python) ### 2.1 EPUB CSS 级联算法 ```python def cascade_stylesheets(default_css: str, replace_css: str, dark_css: str, epub_css: str, user_settings_css: str) -> dict: """ 5 层 CSS 级联: default → replace → dark → epub内嵌 → 用户设置 后面的层覆盖前面的层中相同选择器+属性的值。 Args: default_css: Safari 默认样式 (default.css) replace_css: 微信读书默认替换 (replace.css) dark_css: 暗黑主题 (dark.css) epub_css: EPUB 书籍内嵌 CSS user_settings_css: 用户自定义设置 (字号/行高/主题) Returns: dict: {selector: {property: value}} 合并后的样式表 """ import re def parse_css(css_text: str) -> dict: """解析 CSS 文本为 {selector: {prop: val}} 字典""" rules = {} # 移除注释 css_text = re.sub(r'/\*.*?\*/', '', css_text, flags=re.DOTALL) # 匹配选择器和声明块 for match in re.finditer(r'([^{}]+)\{([^{}]+)\}', css_text): selectors = match.group(1).strip() declarations = match.group(2).strip() for selector in selectors.split(','): selector = selector.strip() if selector not in rules: rules[selector] = {} for decl in declarations.split(';'): decl = decl.strip() if ':' in decl: prop, val = decl.split(':', 1) rules[selector][prop.strip()] = val.strip() return rules def merge(base: dict, override: dict) -> dict: """合并两个样式表, override 覆盖 base""" result = {k: dict(v) for k, v in base.items()} for selector, props in override.items(): if selector not in result: result[selector] = {} result[selector].update(props) return result # 按优先级逐层合并 result = parse_css(default_css) # 层1: 基础 result = merge(result, parse_css(replace_css)) # 层2: 微信读书默认 result = merge(result, parse_css(dark_css)) # 层3: 暗黑主题 result = merge(result, parse_css(epub_css)) # 层4: EPUB 内嵌 result = merge(result, parse_css(user_settings_css)) # 层5: 用户设置 (最高优先级) return result ``` ### 2.2 CoreText 分页算法 ```python def paginate_attributed_string(attr_string_length: int, page_width: float, page_height: float, line_height: float, line_spacing: float, paragraph_spacing: float) -> list: """ 使用 CTTypesetterSuggestLineBreak 逐行排版, 累计高度超过页面高度时换页。 等价于 WRChapterPageCount.recalculatePageRangesForAttributedString Args: attr_string_length: 富文本总字符数 page_width: 页面宽度 (pt) page_height: 页面高度 (pt) line_height: 行高 (pt) line_spacing: 行间距 (pt) paragraph_spacing: 段间距 (pt) Returns: list of (start_index, length) 每页的 NSRange """ page_ranges = [] current_index = 0 page_start = 0 total_height = 0.0 while current_index < attr_string_length: # CTTypesetterSuggestLineBreak: 给定当前位置和可用宽度, 返回本行能放多少字符 # 在真实代码中这是 CoreText 的 C 函数调用 line_break_index = suggest_line_break(current_index, page_width) # 计算本行高度 (包含行间距) current_line_height = line_height + line_spacing # 检查是否需要换页 if total_height + current_line_height > page_height: # 当前页结束 page_ranges.append((page_start, current_index - page_start)) page_start = current_index total_height = 0.0 total_height += current_line_height current_index += line_break_index # 最后一页 if page_start < attr_string_length: page_ranges.append((page_start, attr_string_length - page_start)) return page_ranges def suggest_line_break(start_index: int, available_width: float) -> int: """ 模拟 CTTypesetterSuggestLineBreak 从 start_index 开始, 在 available_width 内能放置的字符数 简化实现: 按空格/标点断行, 累计字符宽度 """ # 实际实现依赖 CTTypesetter, 这里是逻辑等价 # CoreText 内部会考虑: # - 字符宽度 (CTFontGetAdvancesForGlyphs) # - 字间距 (kCTKernAttributeName) # - 断行机会 (Unicode Line Break Algorithm) # - 连字符断行 (CTTypesetterSuggestLineBreakWithHyphenation) pass ``` ### 2.3 avoidPageBreakInside 算法 ```python def avoid_page_break_inside(lines: list, page_height: float, max_lines_to_remove: int = 3) -> list: """ 如果最后几行被截断, 移除最多 max_lines_to_remove 行以避免内容破碎。 等价于 WRCoreTextLayoutFrame.avoidPageBreakInsideByRemovingLastLinesIfNeeded Args: lines: DTCoreTextLayoutLine 列表, 每个有 height 属性 page_height: 页面可用高度 (pt) max_lines_to_remove: 最多移除行数 (默认 3) Returns: 调整后的 lines 列表 """ if not lines: return lines # 计算总内容高度 total_height = sum(line.height for line in lines) # 如果没有溢出, 无需处理 excess_height = total_height - page_height if excess_height <= 0: return lines # 从最后一行开始, 尝试移除行直到不再溢出 removed_height = 0.0 lines_to_remove = 0 for i in range(len(lines) - 1, -1, -1): if lines_to_remove >= max_lines_to_remove: break removed_height += lines[i].height lines_to_remove += 1 if removed_height >= excess_height: break # 移除最后 N 行 if lines_to_remove > 0: return lines[:len(lines) - lines_to_remove] return lines ``` ### 2.4 文本选择 Hit Test 算法 ```python def hit_test_for_character_index(touch_point: tuple, ct_frame_lines: list, view_height: float) -> int: """ 将触摸点转换为字符索引。 等价于 WRPageView 中的 CoreText hit test 逻辑 坐标系转换: UIKit 坐标系: 原点在左上角, Y 轴向下 CoreText 坐标系: 原点在左下角, Y 轴向上 Args: touch_point: (x, y) UIKit 坐标系下的触摸点 ct_frame_lines: CTLine 列表, 每个有 origin 和 string_range view_height: 视图高度 (用于坐标翻转) Returns: 字符在 NSAttributedString 中的索引, -1 表示未命中 """ touch_x, touch_y = touch_point # 坐标翻转: UIKit → CoreText ct_y = view_height - touch_y for line_info in ct_frame_lines: line_origin = line_info['origin'] # (x, y) CoreText 坐标 line_height = line_info['height'] line_ascent = line_info['ascent'] # 检查 Y 坐标是否在本行范围内 # CoreText 的 origin 是基线位置 line_top = line_origin[1] + line_ascent line_bottom = line_origin[1] - (line_height - line_ascent) if line_bottom <= ct_y <= line_top: # 检查 X 坐标 line_width = line_info['width'] if line_origin[0] <= touch_x <= line_origin[0] + line_width: # CTLineGetStringIndexForPosition 的等价逻辑 # 遍历本行的 glyph run, 找到包含触摸点的字符 relative_x = touch_x - line_origin[0] char_index = line_info['string_range'][0] # 遍历 run 中的每个 glyph, 累计宽度 accumulated_width = 0.0 for glyph_info in line_info['glyphs']: accumulated_width += glyph_info['advance'] if accumulated_width >= relative_x: return char_index char_index += 1 return line_info['string_range'][0] + line_info['string_range'][1] - 1 return -1 # 未命中任何行 ``` ### 2.5 DRM 解密算法 ```python import hashlib from Crypto.Cipher import AES def decrypt_chapter(encrypted_data: bytes, book_id: str) -> bytes: """ AES-128-CBC 解密 EPUB 章节文件。 等价于 WREncryptedFileManager.decryptContentsOfFile:forBookId:isFileLost: Args: encrypted_data: 加密的文件数据 book_id: 书籍 ID (用于派生密钥) Returns: 解密后的明文数据 (XHTML) """ # 检查自定义头部标识 "REWD" if encrypted_data[:4] == b'REWD': # 自定义加密格式 header_size = int.from_bytes(encrypted_data[4:8], 'little') iv = encrypted_data[8:24] # 16 字节 IV ciphertext = encrypted_data[24 + header_size:] else: # 标准 AES-128-CBC iv = encrypted_data[:16] # 前 16 字节为 IV ciphertext = encrypted_data[16:] # 密钥派生: book_id → MD5 → 取前 16 字节 key = hashlib.md5(book_id.encode('utf-8')).digest()[:16] # AES-128-CBC 解密, PKCS7 填充 cipher = AES.new(key, AES.MODE_CBC, iv) plaintext = cipher.decrypt(ciphertext) # 移除 PKCS7 填充 pad_len = plaintext[-1] if 1 <= pad_len <= 16: plaintext = plaintext[:-pad_len] return plaintext def encrypt_for_local_storage(plaintext: bytes, book_id: str, original_key: bytes) -> bytes: """ 本地二次加密存储 (DRM 保护)。 等价于 WREncryptedFileManager.encryptFileForBookId: originalEncryptKey:atPath:toPath: 防止直接拷贝文件读取。 """ # 生成本地存储密钥 (与原始密钥不同) local_key = hashlib.md5((book_id + "local").encode('utf-8')).digest()[:16] # 生成随机 IV iv = os.urandom(16) # AES-128-CBC 加密 # PKCS7 填充 pad_len = 16 - (len(plaintext) % 16) plaintext += bytes([pad_len] * pad_len) cipher = AES.new(local_key, AES.MODE_CBC, iv) ciphertext = cipher.encrypt(plaintext) # 输出: "REWD" 标识 + IV + 密文 return b'REWD' + iv + ciphertext ``` ### 2.6 Glyph Path 生成算法 ```python def create_glyph_path(ct_run, ct_line_origin: tuple) -> list: """ 从 CTRun 提取字形并生成 CGPath。 等价于 DTCoreTextGlyphRun.newPathWithGlyphs Args: ct_run: CTRun 对象 ct_line_origin: CTLine 原点 (x, y) Returns: list of CGPath 元素 (moveto/lineto/curveto/close) """ # 获取字形信息 glyph_count = CTRunGetGlyphCount(ct_run) glyphs = (CGGlyph * glyph_count)() positions = (CGPoint * glyph_count)() CTRunGetGlyphs(ct_run, CFRangeMake(0, glyph_count), glyphs) CTRunGetPositions(ct_run, CFRangeMake(0, glyph_count), positions) # 获取字体 attributes = CTRunGetAttributes(ct_run) ct_font = CFDictionaryGetValue(attributes, kCTFontAttributeName) path_elements = [] for i in range(glyph_count): glyph = glyphs[i] position = positions[i] # 转换为绝对坐标 (加上 CTLine 原点) abs_x = ct_line_origin[0] + position.x abs_y = ct_line_origin[1] + position.y # CTFontCreatePathForGlyph: 获取单个字形的轮廓路径 glyph_path = CTFontCreatePathForGlyph(ct_font, glyph, None) if glyph_path: # 将字形路径平移到正确位置 transform = CGAffineTransformMakeTranslation(abs_x, abs_y) transformed_path = CGPathCreateMutableCopyByTransformingPath( glyph_path, transform ) path_elements.append(transformed_path) return path_elements ``` ### 2.7 繁简转换算法 ```python def convert_hans_to_hant(attributed_string: str) -> str: """ 简体中文 → 繁体中文转换。 等价于 WRCoreTextLayouter.convertHansToHantWithAttributedString: 两步法: 简体 → Latin 桥接 → 繁体 (避免 OpenCC 等库的直接转换可能丢失格式) Args: attributed_string: 包含简体中文的富文本 Returns: 转换为繁体的富文本 """ # 步骤1: 使用 ICU 或 OpenCC 进行简→繁映射 # ICU: ubrk_open(UBRK_CHARACTER, "zh-Hant", ...) # OpenCC: opencc_convert("s2t", text) # 简化实现: 使用 Unicode 码点映射表 # 微信读书内部可能使用腾讯自研的繁简转换库 conversion_table = load_hans_to_hant_table() # ~8000 个映射 result = [] for char in attributed_string: if char in conversion_table: result.append(conversion_table[char]) else: result.append(char) return ''.join(result) ``` ### 2.8 预加载预测算法 ```python def should_preload_chapters(book_rank: int, archive_rank: int, user_reading_speed: float, current_chapter_idx: int, total_chapters: int) -> list: """ 预测需要预加载的章节列表。 等价于 WRForecastUtils.shouldPreloadChapterUidsForBook: type:bookRank:archiveRank: Args: book_rank: 书籍热度排名 archive_rank: 用户书架排名 user_reading_speed: 用户阅读速度 (章节/小时) current_chapter_idx: 当前章节索引 total_chapters: 总章节数 Returns: list of chapter_uid 需要预加载的章节 UID 列表 """ preload_chapters = [] # 策略1: 总是预加载下一章 if current_chapter_idx + 1 < total_chapters: preload_chapters.append(current_chapter_idx + 1) # 策略2: 热门书籍预加载更多 (前 1000 名) if book_rank < 1000: # 预加载后 3 章 for i in range(2, 4): if current_chapter_idx + i < total_chapters: preload_chapters.append(current_chapter_idx + i) # 策略3: 用户书架排名靠前的书预加载更多 if archive_rank < 10: # 预加载后 5 章 for i in range(1, 6): if current_chapter_idx + i < total_chapters: preload_chapters.append(current_chapter_idx + i) # 策略4: 快速阅读者预加载更多 if user_reading_speed > 5.0: # 每小时 >5 章 for i in range(1, 8): if current_chapter_idx + i < total_chapters: preload_chapters.append(current_chapter_idx + i) # 去重并排序 return sorted(set(preload_chapters)) ``` ### 2.9 分页范围计算 (二分查找) ```python def page_index_for_character_index(char_index: int, page_ranges: list) -> int: """ 二分查找字符索引所在的页码。 等价于 WRChapterPageCount 中的页码查找逻辑 Args: char_index: 字符在 NSAttributedString 中的索引 page_ranges: [(start, length), ...] 每页的 NSRange Returns: 页码 (0-based), -1 表示未找到 """ if not page_ranges: return -1 left, right = 0, len(page_ranges) - 1 while left <= right: mid = (left + right) // 2 start, length = page_ranges[mid] end = start + length if char_index < start: right = mid - 1 elif char_index >= end: left = mid + 1 else: return mid # 边界情况: 在最后一页之后 return len(page_ranges) - 1 ``` ### 2.10 翻页控制器故障检测 ```python def detect_navigation_direction_crash(page_view_controller, queuing_scroll_view, is_animated: bool) -> bool: """ 检测 UIPageViewController 的 QueuingScrollView 导航方向崩溃。 等价于 WRPageViewController.detectNavigationDirectionCrashWithPageViewController: queuingScrollView:inMethodDidScrollWithAnimation:force:logDetail:logCrashReason: 已知 bug: QueuingScrollView 在快速连续翻页时可能崩溃 原因: 内部 child view controller 管理状态不一致 Returns: True 表示检测到即将崩溃, 需要修复 """ # 检查1: scroll view content offset 是否越界 content_offset = queuing_scroll_view.contentOffset content_size = queuing_scroll_view.contentSize bounds_size = queuing_scroll_view.bounds.size if content_offset.x < -bounds_size.width: return True # 向左越界 if content_offset.x > content_size.width: return True # 向右越界 # 检查2: child view controller 数量是否异常 child_vcs = page_view_controller.childViewControllers if len(child_vcs) > 3: return True # UIPageViewController 最多应该有 3 个 child # 检查3: 当前 page 是否为空 if page_view_controller.viewControllers is None: return True # 检查4: delegate 一致性 if page_view_controller.delegate is None: return True return False ```