Просмотр исходного кода

refactor(R1.4): 排版度量收进不可变快照,消除渲染管线数据竞争

回应审计 F-03。ChapterProvider 的 22 个排版度量此前是逐字段的可变静态量,
主线程写(upStyle/upLayout/notifyViewSizeChange)、IO 线程读(TextChapterLayout
在 Dispatchers.IO 上构造)。既无 happens-before,也无组内原子性——排版协程
可能读到「新 viewWidth 配旧 paddingLeft」的撕裂组合,表现为偶发排版错乱。

- 度量收进 `LayoutMetrics` 不可变 data class,存于单个 @Volatile 字段。
  一次 volatile 写发布整组,一次 volatile 读取得整组。
- 公有访问器保留(改为委托 getter),其余读取方无需改动。
- `upLayout` 的计算抽成纯函数 `withLayout`,由调用方一次性发布,
  杜绝中间态;viewWidth<=0 时只更新 doublePage 的原行为保持不变。
- TextChapterLayout 改用 `ChapterProvider.layoutMetrics()` 一次取整组。
- ContentTextView 的 `visibleRect` 改为 get()——快照化后每次重排会换新的
  RectF 实例,捕获引用会永远停在旧尺寸。
- ReadBook 的 durChapterIndex/durChapterPos 补 @Volatile:layoutLoadedChapter
  在 IO 上读它们判断当前页是否排版完(:1445/:1455/:1462),写在主线程。

审计 F-03 的另两项复核结果:onLayoutPageCompleted 的切主线程已由 B2 完成
(ReadBookController.postRender → handler.post),ReadBook 其余标量在 IO
排版块内既无写入也无读取,不加投机性 @Volatile。

新增 ChapterProviderMetricsInvariantTest 两条护栏(度量不得退回可变静态量、
排版任务不得逐字段读取),均已用注入回归反证会变红。

验证:compileAppDebugKotlin + 413 单测全绿。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5151561 3 месяцев назад
Родитель
Сommit
8e63362f0f

+ 7 - 0
app/src/main/java/io/legado/app/model/ReadBook.kt

@@ -120,8 +120,15 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
     var inBookshelf = false
     var chapterSize = 0
     var simulatedChapterSize = 0
+    // 主线程写(翻页/换章/进度跳转),IO 线程读——layoutLoadedChapter 在
+    // Dispatchers.IO 上用它们判断当前页是否已排版(见 when(chapter.index - durChapterIndex)
+    // 与 page.containPos(durChapterPos))。没有 @Volatile 时 IO 侧可能读到陈旧值,
+    // 表现为「排版完了但没刷新到当前页」。
+    @Volatile
     var durChapterIndex = 0
         private set
+
+    @Volatile
     var durChapterPos = 0
         private set
     var isLocalBook = true

+ 3 - 1
app/src/main/java/io/legado/app/ui/book/read/page/ContentTextView.kt

@@ -53,7 +53,9 @@ class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, at
     private var callBack: CallBack? = null
     private val requireCallBack: CallBack
         get() = callBack ?: activity as CallBack
-    private val visibleRect = ChapterProvider.visibleRect
+    // 每次读取——ChapterProvider 的排版度量是不可变快照,重新排版会换成新的 RectF 实例,
+    // 捕获引用会永远停在旧尺寸上。
+    private val visibleRect get() = ChapterProvider.visibleRect
     val selectStart = TextPos(0, -1, -1)
     private val selectEnd = TextPos(0, -1, -1)
     val selectEndPosition: TextPos get() = selectEnd

+ 140 - 96
app/src/main/java/io/legado/app/ui/book/read/page/provider/ChapterProvider.kt

@@ -58,105 +58,135 @@ object ChapterProvider {
 
     var dashEffect = DashPathEffect(floatArrayOf(dottedBase, dottedRatio), 0f)
 
+    /**
+     * 排版度量的不可变快照。
+     *
+     * 这些值由主线程写入([upStyle] / [upLayout] / [notifyViewSizeChange]),由 IO 线程上
+     * 构造的 [TextChapterLayout] 与绘制路径读取。逐字段的可变静态量既无 happens-before、
+     * 也无组内原子性——排版协程可能读到「新的 viewWidth 配旧的 paddingLeft」这种撕裂组合,
+     * 表现为偶发排版错乱。收进一个不可变对象后,一次 volatile 写发布整组、一次 volatile
+     * 读取得整组,两个问题一并消除。
+     *
+     * 注意:[titlePaint] / [contentPaint] 是可变对象,这里持有的是引用。[upThemeColors]
+     * 仍会就地改它们的颜色——颜色不参与测量,故不影响排版结果;真要根治需让排版任务持有
+     * 自己的 Paint 副本,属 Track D2 范围。
+     */
+    internal data class LayoutMetrics(
+        val viewWidth: Int = 0,
+        val viewHeight: Int = 0,
+        val paddingLeft: Int = 0,
+        val paddingTop: Int = 0,
+        val paddingRight: Int = 0,
+        val paddingBottom: Int = 0,
+        val visibleWidth: Int = 0,
+        val visibleHeight: Int = 0,
+        val visibleRight: Int = 0,
+        val visibleBottom: Int = 0,
+        val lineSpacingExtra: Float = 0f,
+        val titleLineSpacingExtra: Float = 0f,
+        val titleLineSpacingSub: Float = 0f,
+        val paragraphSpacing: Int = 0,
+        val titleTopSpacing: Int = 0,
+        val titleBottomSpacing: Int = 0,
+        val indentCharWidth: Float = 0f,
+        val titlePaintTextHeight: Float = 0f,
+        val contentPaintTextHeight: Float = 0f,
+        val titlePaintFontMetrics: FontMetrics = FontMetrics(),
+        val contentPaintFontMetrics: FontMetrics = FontMetrics(),
+        val typeface: Typeface? = Typeface.DEFAULT,
+        val titlePaint: TextPaint = TextPaint(),
+        val contentPaint: TextPaint = TextPaint(),
+        val doublePage: Boolean = false,
+        val visibleRect: RectF = RectF(),
+    )
+
+    @Volatile
+    private var metrics = LayoutMetrics()
+
+    /**
+     * 原子地取整组排版度量。需要多个度量彼此自洽的调用方(典型是排版任务)用它,
+     * 而不是逐个读下面的便捷访问器。
+     */
+    internal fun layoutMetrics(): LayoutMetrics = metrics
+
     @JvmStatic
-    var viewWidth = 0
-        private set
+    val viewWidth get() = metrics.viewWidth
 
     @JvmStatic
-    var viewHeight = 0
-        private set
+    val viewHeight get() = metrics.viewHeight
 
     @JvmStatic
-    var paddingLeft = 0
-        private set
+    val paddingLeft get() = metrics.paddingLeft
 
     @JvmStatic
-    var paddingTop = 0
-        private set
+    val paddingTop get() = metrics.paddingTop
 
     @JvmStatic
-    var paddingRight = 0
-        private set
+    val paddingRight get() = metrics.paddingRight
 
     @JvmStatic
-    var paddingBottom = 0
-        private set
+    val paddingBottom get() = metrics.paddingBottom
 
     @JvmStatic
-    var visibleWidth = 0
-        private set
+    val visibleWidth get() = metrics.visibleWidth
 
     @JvmStatic
-    var visibleHeight = 0
-        private set
+    val visibleHeight get() = metrics.visibleHeight
 
     @JvmStatic
-    var visibleRight = 0
-        private set
+    val visibleRight get() = metrics.visibleRight
 
     @JvmStatic
-    var visibleBottom = 0
-        private set
+    val visibleBottom get() = metrics.visibleBottom
 
     @JvmStatic
-    var lineSpacingExtra = 0f
-        private set
+    val lineSpacingExtra get() = metrics.lineSpacingExtra
 
-    var titleLineSpacingExtra = 0f
-        private set
+    val titleLineSpacingExtra get() = metrics.titleLineSpacingExtra
 
-    var titleLineSpacingSub = 0f
-        private set
+    val titleLineSpacingSub get() = metrics.titleLineSpacingSub
 
     @JvmStatic
-    var paragraphSpacing = 0
-        private set
+    val paragraphSpacing get() = metrics.paragraphSpacing
 
     @JvmStatic
-    var titleTopSpacing = 0
-        private set
+    val titleTopSpacing get() = metrics.titleTopSpacing
 
     @JvmStatic
-    var titleBottomSpacing = 0
-        private set
+    val titleBottomSpacing get() = metrics.titleBottomSpacing
 
     @JvmStatic
-    var indentCharWidth = 0f
-        private set
+    val indentCharWidth get() = metrics.indentCharWidth
 
     @JvmStatic
-    var titlePaintTextHeight = 0f
-        private set
+    val titlePaintTextHeight get() = metrics.titlePaintTextHeight
 
     @JvmStatic
-    var contentPaintTextHeight = 0f
-        private set
+    val contentPaintTextHeight get() = metrics.contentPaintTextHeight
 
     @JvmStatic
-    var titlePaintFontMetrics = FontMetrics()
+    val titlePaintFontMetrics get() = metrics.titlePaintFontMetrics
 
     @JvmStatic
-    var contentPaintFontMetrics = FontMetrics()
+    val contentPaintFontMetrics get() = metrics.contentPaintFontMetrics
 
     @JvmStatic
-    var typeface: Typeface? = Typeface.DEFAULT
-        private set
+    val typeface get() = metrics.typeface
 
     @JvmStatic
-    var titlePaint: TextPaint = TextPaint()
+    val titlePaint get() = metrics.titlePaint
 
     @JvmStatic
-    var contentPaint: TextPaint = TextPaint()
+    val contentPaint get() = metrics.contentPaint
 
     @JvmStatic
     var reviewPaint: TextPaint = TextPaint()
 
     @JvmStatic
-    var doublePage = false
-        private set
+    val doublePage get() = metrics.doublePage
 
     @JvmStatic
-    var visibleRect = RectF()
+    val visibleRect get() = metrics.visibleRect
 
     private val handler by lazy {
         buildMainHandler()
@@ -868,27 +898,14 @@ object ChapterProvider {
      * 更新样式
      */
     fun upStyle() {
-        typeface = getTypeface(ReadBookConfig.textFont)
-        getPaints(typeface).let {
-            titlePaint = it.first
-            contentPaint = it.second
-//            reviewPaint.color = contentPaint.color
-//            reviewPaint.textSize = contentPaint.textSize * 0.45f
-//            reviewPaint.textAlign = Paint.Align.CENTER
-        }
+        val typeface = getTypeface(ReadBookConfig.textFont)
+        val (titlePaint, contentPaint) = getPaints(typeface)
         dashEffect = DashPathEffect(
             floatArrayOf(ReadBookConfig.durConfig.dottedBase, ReadBookConfig.durConfig.dottedRatio),
             0f
         )
-        //间距
-        lineSpacingExtra = ReadBookConfig.lineSpacingExtra / 10f
-        titleLineSpacingExtra = ReadBookConfig.titleLineSpacingExtra / 10f
-        titleLineSpacingSub = ReadBookConfig.titleLineSpacingSub / 10f
-        paragraphSpacing = ReadBookConfig.paragraphSpacing
-        titleTopSpacing = ReadBookConfig.titleTopSpacing.dpToPx()
-        titleBottomSpacing = ReadBookConfig.titleBottomSpacing.dpToPx()
         val bodyIndent = ReadBookConfig.paragraphIndent
-        indentCharWidth = if (bodyIndent.isNotEmpty()) {
+        val indentCharWidth = if (bodyIndent.isNotEmpty()) {
             var indentWidth = StaticLayout.getDesiredWidth(bodyIndent, contentPaint)
             if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
                 indentWidth += contentPaint.letterSpacing * contentPaint.textSize
@@ -897,11 +914,26 @@ object ChapterProvider {
         } else {
             0f
         }
-        titlePaintTextHeight = titlePaint.textHeight
-        contentPaintTextHeight = contentPaint.textHeight
-        titlePaintFontMetrics = titlePaint.fontMetrics
-        contentPaintFontMetrics = contentPaint.fontMetrics
-        upLayout()
+        // 样式与布局一起算完再发布,避免中间态被排版协程读到
+        metrics = withLayout(
+            metrics.copy(
+                typeface = typeface,
+                titlePaint = titlePaint,
+                contentPaint = contentPaint,
+                //间距
+                lineSpacingExtra = ReadBookConfig.lineSpacingExtra / 10f,
+                titleLineSpacingExtra = ReadBookConfig.titleLineSpacingExtra / 10f,
+                titleLineSpacingSub = ReadBookConfig.titleLineSpacingSub / 10f,
+                paragraphSpacing = ReadBookConfig.paragraphSpacing,
+                titleTopSpacing = ReadBookConfig.titleTopSpacing.dpToPx(),
+                titleBottomSpacing = ReadBookConfig.titleBottomSpacing.dpToPx(),
+                indentCharWidth = indentCharWidth,
+                titlePaintTextHeight = titlePaint.textHeight,
+                contentPaintTextHeight = contentPaint.textHeight,
+                titlePaintFontMetrics = titlePaint.fontMetrics,
+                contentPaintFontMetrics = contentPaint.fontMetrics,
+            )
+        )
     }
 
     /** 主题切换只更新绘制颜色,不触发字体加载或正文重排。 */
@@ -1105,9 +1137,7 @@ object ChapterProvider {
     }
 
     private fun notifyViewSizeChange(width: Int, height: Int) {
-        viewWidth = width
-        viewHeight = height
-        upLayout()
+        metrics = withLayout(metrics.copy(viewWidth = width, viewHeight = height))
         ReadBook.requestWholeBookPageEstimate()
         ReadConfigUpdateBus.post(setOf(ConfigUpdateAction.RelayoutContent))
     }
@@ -1116,47 +1146,49 @@ object ChapterProvider {
      * 更新绘制尺寸
      */
     fun upLayout() {
-        when (ReadConfig.doubleHorizontalPage) {
-            "0" -> doublePage = false
-            "1" -> doublePage = true
-            "2" -> {
-                doublePage = (viewWidth > viewHeight)
-                        && ReadBook.pageAnim() != 3
-            }
+        metrics = withLayout(metrics)
+    }
 
-            "3" -> {
-                doublePage = (viewWidth > viewHeight || appCtx.isPad)
-                        && ReadBook.pageAnim() != 3
-            }
+    /**
+     * 由 [base] 的样式与视图尺寸推导全部绘制尺寸,返回新快照。纯函数,不写全局——
+     * 调用方负责一次性发布,保证排版协程读不到中间态。
+     */
+    private fun withLayout(base: LayoutMetrics): LayoutMetrics {
+        val doublePage = when (ReadConfig.doubleHorizontalPage) {
+            "0" -> false
+            "1" -> true
+            "2" -> (base.viewWidth > base.viewHeight) && ReadBook.pageAnim() != 3
+            "3" -> (base.viewWidth > base.viewHeight || appCtx.isPad) && ReadBook.pageAnim() != 3
+            else -> base.doublePage
         }
 
-        if (viewWidth <= 0 || viewHeight <= 0) {
-            return
+        if (base.viewWidth <= 0 || base.viewHeight <= 0) {
+            return base.copy(doublePage = doublePage)
         }
 
-        paddingLeft = ReadBookConfig.paddingLeft.dpToPx()
-        paddingTop = ReadBookConfig.paddingTop.dpToPx()
-        paddingRight = ReadBookConfig.paddingRight.dpToPx()
-        paddingBottom = ReadBookConfig.paddingBottom.dpToPx()
-        visibleWidth = if (doublePage) {
-            viewWidth / 2 - paddingLeft - paddingRight
+        val paddingLeft = ReadBookConfig.paddingLeft.dpToPx()
+        val paddingTop = ReadBookConfig.paddingTop.dpToPx()
+        val paddingRight = ReadBookConfig.paddingRight.dpToPx()
+        val paddingBottom = ReadBookConfig.paddingBottom.dpToPx()
+        val visibleWidth = if (doublePage) {
+            base.viewWidth / 2 - paddingLeft - paddingRight
         } else {
-            viewWidth - paddingLeft - paddingRight
+            base.viewWidth - paddingLeft - paddingRight
         }
         //留1dp画最后一行下划线
-        visibleHeight = viewHeight - paddingTop - paddingBottom
-        visibleRight = viewWidth - paddingRight
-        visibleBottom = paddingTop + visibleHeight
+        val visibleHeight = base.viewHeight - paddingTop - paddingBottom
+        val visibleRight = base.viewWidth - paddingRight
+        val visibleBottom = paddingTop + visibleHeight
 
         val shadowPad = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
-            (contentPaint.shadowLayerRadius + 2).toInt()
+            (base.contentPaint.shadowLayerRadius + 2).toInt()
         } else {
             20
         }
 
         val italicPad = if (ReadBookConfig.textItalic)  (ReadBookConfig.textSize * 0.25f).spToPx() else 0f
 
-        visibleRect.set(
+        val visibleRect = RectF(
             (paddingLeft - shadowPad - italicPad),
             (paddingTop - shadowPad).toFloat(),
             (visibleRight + shadowPad + italicPad),
@@ -1164,6 +1196,18 @@ object ChapterProvider {
         )
 
         //TODO: 有关测量相关问题
+        return base.copy(
+            doublePage = doublePage,
+            paddingLeft = paddingLeft,
+            paddingTop = paddingTop,
+            paddingRight = paddingRight,
+            paddingBottom = paddingBottom,
+            visibleWidth = visibleWidth,
+            visibleHeight = visibleHeight,
+            visibleRight = visibleRight,
+            visibleBottom = visibleBottom,
+            visibleRect = visibleRect,
+        )
     }
 
 }

+ 29 - 24
app/src/main/java/io/legado/app/ui/book/read/page/provider/TextChapterLayout.kt

@@ -118,30 +118,35 @@ class TextChapterLayout(
     @Volatile
     private var listener: LayoutProgressListener? = textChapter
 
-    private val paddingLeft = ChapterProvider.paddingLeft
-    private val paddingTop = ChapterProvider.paddingTop
-
-    private val titlePaint = ChapterProvider.titlePaint
-    private val titlePaintTextHeight = ChapterProvider.titlePaintTextHeight
-    private val titlePaintFontMetrics = ChapterProvider.titlePaintFontMetrics
-
-    private val contentPaint = ChapterProvider.contentPaint
-    private val contentPaintTextHeight = ChapterProvider.contentPaintTextHeight
-    private val contentPaintFontMetrics = ChapterProvider.contentPaintFontMetrics
-
-    private val titleTopSpacing = ChapterProvider.titleTopSpacing
-    private val titleBottomSpacing = ChapterProvider.titleBottomSpacing
-    private val titleLineSpacingExtra = ChapterProvider.titleLineSpacingExtra
-    private val titleLineSpacingSub = ChapterProvider.titleLineSpacingSub
-    private val lineSpacingExtra = ChapterProvider.lineSpacingExtra
-    private val paragraphSpacing = ChapterProvider.paragraphSpacing
-
-    internal val visibleHeight = ChapterProvider.visibleHeight
-    internal val visibleWidth = ChapterProvider.visibleWidth
-
-    private val viewWidth = ChapterProvider.viewWidth
-    private val doublePage = ChapterProvider.doublePage
-    private val indentCharWidth = ChapterProvider.indentCharWidth
+    // 一次取到整组排版度量:ChapterProvider 的快照是单个 volatile 字段,读一次即得到
+    // 内部自洽的一组值。逐字段读会拿到「新 viewWidth 配旧 paddingLeft」这种撕裂组合
+    // ——本类在 IO 线程构造,而度量由主线程写入。
+    private val metrics = ChapterProvider.layoutMetrics()
+
+    private val paddingLeft = metrics.paddingLeft
+    private val paddingTop = metrics.paddingTop
+
+    private val titlePaint = metrics.titlePaint
+    private val titlePaintTextHeight = metrics.titlePaintTextHeight
+    private val titlePaintFontMetrics = metrics.titlePaintFontMetrics
+
+    private val contentPaint = metrics.contentPaint
+    private val contentPaintTextHeight = metrics.contentPaintTextHeight
+    private val contentPaintFontMetrics = metrics.contentPaintFontMetrics
+
+    private val titleTopSpacing = metrics.titleTopSpacing
+    private val titleBottomSpacing = metrics.titleBottomSpacing
+    private val titleLineSpacingExtra = metrics.titleLineSpacingExtra
+    private val titleLineSpacingSub = metrics.titleLineSpacingSub
+    private val lineSpacingExtra = metrics.lineSpacingExtra
+    private val paragraphSpacing = metrics.paragraphSpacing
+
+    internal val visibleHeight = metrics.visibleHeight
+    internal val visibleWidth = metrics.visibleWidth
+
+    private val viewWidth = metrics.viewWidth
+    private val doublePage = metrics.doublePage
+    private val indentCharWidth = metrics.indentCharWidth
     private val stringBuilder = StringBuilder()
 
     private val paragraphIndent = ReadBookConfig.paragraphIndent

+ 93 - 0
app/src/test/java/io/legado/app/ui/book/read/page/provider/ChapterProviderMetricsInvariantTest.kt

@@ -0,0 +1,93 @@
+package io.legado.app.ui.book.read.page.provider
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import java.io.File
+
+/**
+ * R1.4 —— 排版度量必须留在不可变快照里。
+ *
+ * 这些值由主线程写(`upStyle`/`upLayout`/`notifyViewSizeChange`),由 IO 线程上构造的
+ * [TextChapterLayout] 读。改回逐字段的可变静态量会同时丢掉两件事:跨线程的
+ * happens-before,以及组内原子性(读到「新 viewWidth 配旧 paddingLeft」的撕裂组合)。
+ *
+ * 两条不变式都做源码扫描——`ChapterProvider` 是 object 且依赖 Android 图形类型,
+ * JVM 单测里无法构造。
+ */
+class ChapterProviderMetricsInvariantTest {
+
+    @Test
+    fun `排版度量不以可变静态量形式存在`() {
+        val offenders = MUTABLE_METRIC.findAll(chapterProviderSource())
+            .map { it.groupValues[1] }
+            .filterNot { it in ALLOWED_MUTABLE }
+            .toList()
+            .sorted()
+
+        assertEquals(
+            "以下排版度量退回了可变静态量,请放进 LayoutMetrics 快照:\n" +
+                offenders.joinToString("\n") { "  - var $it" },
+            emptyList<String>(),
+            offenders,
+        )
+    }
+
+    @Test
+    fun `排版任务一次性取整组度量而非逐字段读取`() {
+        // 编译期常量随便读——它们不是度量,不会变。
+        val constants = Regex("""const val ([a-zA-Z][A-Za-z0-9_]*)""")
+            .findAll(chapterProviderSource())
+            .map { it.groupValues[1] }
+            .toSet() + "layoutMetrics"
+
+        val offenders = textChapterLayoutSource().lineSequence()
+            .filterNot { it.trimStart().startsWith("import ") }
+            .flatMap { line -> MEMBER_ACCESS.findAll(line).map { it.groupValues[1] } }
+            .filterNot { it in constants }
+            .toList()
+            .distinct()
+            .sorted()
+
+        assertEquals(
+            "TextChapterLayout 在 IO 线程构造,必须用 ChapterProvider.layoutMetrics() " +
+                "一次取到自洽的一组度量。以下是逐字段读取:\n" +
+                offenders.joinToString("\n") { "  - ChapterProvider.$it" },
+            emptyList<String>(),
+            offenders,
+        )
+    }
+
+    private fun chapterProviderSource(): String =
+        mainSourceFile("ChapterProvider.kt").readText()
+
+    private fun textChapterLayoutSource(): String =
+        mainSourceFile("TextChapterLayout.kt").readText()
+
+    private fun mainSourceFile(fileName: String): File {
+        val relativePath = "io/legado/app/ui/book/read/page/provider/$fileName"
+        var directory: File? = File("").absoluteFile
+        while (directory != null) {
+            for (prefix in listOf("src/main/java", "app/src/main/java")) {
+                val candidate = File(directory, "$prefix/$relativePath")
+                if (candidate.isFile) return candidate
+            }
+            directory = directory.parentFile
+        }
+        error("从 ${File("").absolutePath} 向上找不到 $relativePath")
+    }
+
+    private companion object {
+        val MEMBER_ACCESS = Regex("""\bChapterProvider\.([a-zA-Z][A-Za-z0-9_]*)""")
+
+        /** object 顶层(4 空格缩进)的 `var 名字`,跳过 data class 里的 `val`。 */
+        val MUTABLE_METRIC = Regex("""^ {4}(?:@JvmStatic\s+)?var ([a-zA-Z][A-Za-z0-9_]*)""", RegexOption.MULTILINE)
+
+        /**
+         * 允许保留可变的成员:
+         * - `dashEffect` / `reviewPaint`:绘制期读取的 Paint 类对象,本身可变,
+         *   放进不可变快照没有意义(根治要让排版任务持有自己的副本,属 Track D2)。
+         * - `upViewSizeRunnable`:主线程去抖用的 Runnable 句柄,不跨线程。
+         */
+        val ALLOWED_MUTABLE = setOf("dashEffect", "reviewPaint", "upViewSizeRunnable")
+    }
+}