Skip to content

Commit c516140

Browse files
committed
fix: ViewPool 安全性改进
- ViewCleaner: 默认归一化 visibility/isEnabled/isSelected/isActivated/contentDescription - ViewPool.recycle: 拒绝仍 attached 的 view 入池 - ViewPool.obtain: 有界尝试替代 while+repeat,避免并发活锁 - ViewPool: 新增 opt-in host(Activity) 隔离 + clearForHost - FastInflater: onActivityDestroyed 委托 clearForHost - 补充单元测试覆盖以上改动
1 parent 897c48e commit c516140

6 files changed

Lines changed: 350 additions & 32 deletions

File tree

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,9 @@ val binding = FastDataBinding.inflate<ItemFeedBinding>(
111111

112112
### 自定义回收策略
113113

114-
默认 `ViewCleaner` 只清理 Android 基础 View 状态,例如文本、图片、listener、alpha、translation、scale、scroll 和动画。业务自定义状态不会被自动识别,例如:
114+
默认 `ViewCleaner` 只清理 Android 基础 View 状态,例如文本、图片、listener、alpha、translation、scale、scroll 和动画。**此外还会将 `visibility` 归一化为 `VISIBLE``isEnabled` 归一化为 `true``isSelected`/`isActivated` 归一化为 `false``contentDescription` 清空。** 如果布局 XML 中某些子 View 的静态默认值不是这些(例如默认 `GONE` 的占位 View、默认 `disabled` 的按钮、固定的无障碍文案),复用后需要在 bind 阶段或 `ViewRecyclePolicy.onObtain()` 中恢复。
115+
116+
业务自定义状态不会被自动识别,例如:
115117

116118
- 自定义 View 内部的展开/折叠变量、选中缓存、加载状态
117119
- 运行时替换的特殊背景、前景、Drawable callback

lib/src/main/java/com/github/donglua/fastinflater/FastInflater.kt

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ class FastInflater private constructor(context: Context) {
5252
override fun onActivityStopped(activity: Activity) = Unit
5353
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit
5454
override fun onActivityDestroyed(activity: Activity) {
55-
viewPool.clear()
55+
viewPool.clearForHost(activity)
5656
}
5757
}
5858
)
@@ -234,6 +234,23 @@ class FastInflater private constructor(context: Context) {
234234
viewPool.setFactoryIsolation(enabled)
235235
}
236236

237+
/**
238+
* 开启/关闭 host(Activity)隔离。默认关闭。
239+
*
240+
* 开启后不同 Activity 的 View 不会串池,避免 context/theme 不一致导致的 UI 异常。
241+
* 关闭后所有 context 共享同一个桶,适合全局 theme 一致的项目(绝大多数情况)。
242+
*
243+
* 开启后建议用 Activity context 做 warmUp,以确保预热的 View 进入对应 Activity 的桶。
244+
* 切换时会清空池。
245+
*/
246+
fun setHostIsolation(enabled: Boolean) {
247+
viewPool.setHostIsolation(enabled)
248+
}
249+
250+
fun isHostIsolationEnabled(): Boolean {
251+
return viewPool.isHostIsolationEnabled()
252+
}
253+
237254
/**
238255
* 一键开关诊断埋点([InflateTracker] + [PoolStats])。
239256
*

lib/src/main/java/com/github/donglua/fastinflater/ViewCleaner.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,15 @@ object ViewCleaner {
4343
view.clearAnimation()
4444
view.clearFocus()
4545

46+
// ⚠️ 以下属性会被归一化到"可见/可用"默认值。
47+
// 如果布局 XML 中某些子 View 的静态默认值不是 VISIBLE / enabled / 无 contentDescription,
48+
// 复用后需要在 bind 阶段或 ViewRecyclePolicy.onObtain() 中恢复。
49+
view.visibility = View.VISIBLE
50+
view.isEnabled = true
51+
view.isSelected = false
52+
view.isActivated = false
53+
view.contentDescription = null
54+
4655
if (view is TextView) {
4756
view.text = null
4857
view.setOnEditorActionListener(null)
@@ -55,6 +64,7 @@ object ViewCleaner {
5564
}
5665
if (view is CompoundButton) {
5766
view.setOnCheckedChangeListener(null)
67+
view.isChecked = false
5868
}
5969
}
6070
}

lib/src/main/java/com/github/donglua/fastinflater/ViewPool.kt

Lines changed: 146 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package com.github.donglua.fastinflater
22

3+
import android.app.Activity
34
import android.content.Context
5+
import android.content.ContextWrapper
46
import android.os.Handler
57
import android.os.Looper
68
import android.util.Xml
@@ -31,10 +33,9 @@ class ViewPool {
3133
}
3234

3335
/**
34-
* Pool key 用 Long 表示。
35-
* - 默认(factoryIsolation = false):key = layoutId(直接放低 32 位),不访问 Context
36-
* - 开启 factory 隔离时:高 32 位 layoutId,低 32 位 factory hash
37-
* 用 Long 而非 data class 是为了避免每次 obtain/recycle 都分配对象。
36+
* Pool key 用 Long 表示,避免每次 obtain/recycle 分配对象。
37+
* - 高 32 位:layoutId
38+
* - 低 32 位:隔离 hash(hostHash xor factoryHash,两者都关时为 0)
3839
*/
3940
private val pool = ConcurrentHashMap<Long, ConcurrentLinkedDeque<View>>()
4041
private val policies = ConcurrentHashMap<Int, ViewRecyclePolicy>()
@@ -59,6 +60,20 @@ class ViewPool {
5960
@Volatile
6061
private var factoryIsolation = false
6162

63+
/**
64+
* 开启/关闭 host(Activity)隔离。默认关闭。
65+
*
66+
* 开启后 pool key 包含 Activity identityHashCode,
67+
* 不同 Activity 的 View 不会串池,避免 context/theme 不一致导致的 UI 异常。
68+
*
69+
* 注意:开启后 Application context 预热的 View 进入独立桶(hostHash=0),
70+
* 不会被 Activity context 的 obtain 命中。请改用 Activity context 做 warmUp。
71+
*
72+
* 关闭时所有 context 共享同一个桶,适合全局 theme 一致的项目(绝大多数情况)。
73+
*/
74+
@Volatile
75+
private var hostIsolation = false
76+
6277
private var defaultMaxPoolSize = 4
6378

6479
fun setMaxPoolSize(size: Int) {
@@ -94,6 +109,47 @@ class ViewPool {
94109

95110
fun isFactoryIsolationEnabled(): Boolean = factoryIsolation
96111

112+
/**
113+
* 开启/关闭 host(Activity)隔离。默认关闭。
114+
*
115+
* 开启后不同 Activity 的 View 不会串池,Application context 预热的 View 进入共享桶。
116+
* 关闭后所有 context 共享同一个桶,适合全局 theme 一致的项目(绝大多数情况)。
117+
*
118+
* 切换时会清空池。
119+
*/
120+
fun setHostIsolation(enabled: Boolean) {
121+
if (hostIsolation == enabled) return
122+
hostIsolation = enabled
123+
clear()
124+
}
125+
126+
fun isHostIsolationEnabled(): Boolean = hostIsolation
127+
128+
/**
129+
* 清除指定 Activity 关联的所有池条目。
130+
* 在 Activity.onDestroy 时调用,避免池中 View 持有已销毁 Activity 的 context。
131+
*
132+
* - hostIsolation 关闭:退化为 [clear](无法按 host 区分)。
133+
* - hostIsolation + factoryIsolation 同时开启:低 32 位是 hostHash xor factoryHash,
134+
* 无法仅凭 activityHash 精确匹配,退化为 [clear] 避免泄漏。
135+
* - 仅 hostIsolation 开启:精确删除低 32 位等于 activityHash 的条目。
136+
*/
137+
fun clearForHost(activity: Activity) {
138+
if (!hostIsolation || factoryIsolation) {
139+
clear()
140+
return
141+
}
142+
poolGeneration.incrementAndGet()
143+
val activityHash = System.identityHashCode(activity)
144+
val mask = 0xFFFFFFFFL
145+
pool.keys.removeIf { key ->
146+
(key and mask).toInt() == activityHash
147+
}
148+
warmingCounts.keys.removeIf { key ->
149+
(key and mask).toInt() == activityHash
150+
}
151+
}
152+
97153
/**
98154
* 控制某个布局是否允许进入 FastInflater 池。
99155
*
@@ -122,30 +178,51 @@ class ViewPool {
122178
*
123179
* 池中的 View 在 [recycle] 时已经被清理过(默认 [ViewCleaner.clean] 或 [ViewRecyclePolicy.onRecycle]),
124180
* View 处于 detached 状态、不会被外部修改,所以这里不再重复清理整棵 View 树。
181+
*
182+
* 并发安全:用有界尝试(snapshot size)替代无界 while 循环,避免并发 offer 导致的活锁。
183+
* 不兼容的 View 暂存本地列表,最后统一放回队尾,保留给后续兼容的 parent 使用。
125184
*/
126185
fun obtain(@LayoutRes layoutId: Int, context: Context, parent: ViewGroup? = null): View? {
127186
if (isPoolingDisabled(layoutId)) return null
128187
val key = keyFor(layoutId, context)
188+
return pollCompatible(key, layoutId, context, parent)
189+
}
190+
191+
/**
192+
* 从指定 deque 中 poll 出第一个与 parent 兼容的 View。
193+
* 最多尝试 deque 当前 size 次(快照),不兼容的 View 暂存后统一放回队尾。
194+
*/
195+
private fun pollCompatible(
196+
key: Long,
197+
@LayoutRes layoutId: Int,
198+
context: Context,
199+
parent: ViewGroup?
200+
): View? {
129201
val deque = pool[key] ?: return null
130-
while (true) {
131-
val view = deque.poll() ?: return null
132-
if (!ensureAttachableToParent(layoutId, context, parent, view)) {
133-
deque.offerLast(view)
134-
val remaining = (deque.size - 1).coerceAtLeast(0)
135-
repeat(remaining) {
136-
val next = deque.poll() ?: return null
137-
if (ensureAttachableToParent(layoutId, context, parent, next)) {
138-
policies[layoutId]?.onObtain(next)
139-
return next
140-
}
141-
deque.offerLast(next)
142-
}
143-
return null
202+
val maxAttempts = deque.size
203+
if (maxAttempts == 0) return null
204+
205+
val rejected = ArrayList<View>(4)
206+
var found: View? = null
207+
208+
for (i in 0 until maxAttempts) {
209+
val view = deque.poll() ?: break
210+
if (ensureAttachableToParent(layoutId, context, parent, view)) {
211+
found = view
212+
break
144213
}
145-
// 只跑用户自定义的 onObtain 钩子;默认情况下 obtain 是纯 poll
146-
policies[layoutId]?.onObtain(view)
147-
return view
214+
rejected.add(view)
215+
}
216+
217+
// 不兼容的 View 放回队尾,保留给后续兼容的 parent 使用
218+
for (view in rejected) {
219+
deque.offerLast(view)
220+
}
221+
222+
if (found != null) {
223+
policies[layoutId]?.onObtain(found)
148224
}
225+
return found
149226
}
150227

151228
/**
@@ -377,12 +454,14 @@ class ViewPool {
377454
}
378455

379456
private fun clearLayout(@LayoutRes layoutId: Int) {
380-
if (!factoryIsolation) {
381-
val key = layoutId.toLong()
457+
if (!hostIsolation && !factoryIsolation) {
458+
// 无隔离模式:key = layoutId << 32,直接移除
459+
val key = layoutId.toLong() shl 32
382460
pool.remove(key)
383461
warmingCounts.remove(key)
384462
return
385463
}
464+
// 有隔离模式:高 32 位匹配 layoutId 的所有 key 都要移除
386465
pool.keys.removeIf { key ->
387466
(key ushr 32).toInt() == layoutId
388467
}
@@ -428,16 +507,47 @@ class ViewPool {
428507
}
429508

430509
/**
431-
* 计算 pool key。默认仅基于 layoutId,零额外开销、不访问 Context。
432-
* 开启 factoryIsolation 时,把 LayoutInflater.factory2 的 class hash 编入低 32 位。
510+
* 计算 pool key。
511+
* - 高 32 位始终为 layoutId
512+
* - 低 32 位根据隔离模式组合 hostHash 和 factoryHash
513+
* - hostIsolation=true: 包含 Activity identityHashCode(Application context 为 0)
514+
* - factoryIsolation=true: 包含 LayoutInflater.factory2 的 class hashCode
515+
* - 两者都关: 低 32 位为 0,等价于旧行为
433516
*/
434517
private fun keyFor(@LayoutRes layoutId: Int, context: Context): Long {
435-
if (!factoryIsolation) {
436-
return layoutId.toLong()
518+
val high = layoutId.toLong() shl 32
519+
if (!hostIsolation && !factoryIsolation) {
520+
return high
521+
}
522+
var low = 0
523+
if (hostIsolation) {
524+
low = hostHash(context)
525+
}
526+
if (factoryIsolation) {
527+
low = low xor factoryHash(context)
528+
}
529+
return high or (low.toLong() and 0xFFFFFFFFL)
530+
}
531+
532+
/**
533+
* 返回 context 所属 Activity 的 identityHashCode,用于 host 隔离。
534+
* 非 Activity context(Application / Service)返回 0,进入共享桶。
535+
*/
536+
private fun hostHash(context: Context): Int {
537+
val activity = unwrapActivity(context) ?: return 0
538+
return System.identityHashCode(activity)
539+
}
540+
541+
/**
542+
* 沿 ContextWrapper 链向上查找 Activity。
543+
*/
544+
private fun unwrapActivity(context: Context): Activity? {
545+
var ctx: Context? = context
546+
while (ctx != null) {
547+
if (ctx is Activity) return ctx
548+
ctx = if (ctx is ContextWrapper) ctx.baseContext else null
437549
}
438-
// 高 32 位 layoutId,低 32 位 factory hash
439-
val hash = factoryHash(context)
440-
return (layoutId.toLong() shl 32) or (hash.toLong() and 0xFFFFFFFFL)
550+
return null
441551
}
442552

443553
private fun factoryHash(context: Context): Int {
@@ -489,6 +599,12 @@ class ViewPool {
489599
}
490600

491601
private fun canEnterPool(@LayoutRes layoutId: Int, view: View): Boolean {
602+
// 仍 attached 的 view 不能入池:下一次 obtain + parent.addView 会抛 IllegalStateException
603+
// 如果业务确认安全(已经 detach 或者 parent 即将丢弃),可以注册 ViewRecyclePolicy 覆盖默认行为
604+
if (view.parent != null) {
605+
val policy = policies[layoutId] ?: return false
606+
return policy.canRecycle(view)
607+
}
492608
return policies[layoutId]?.canRecycle(view) ?: true
493609
}
494610

lib/src/test/java/com/github/donglua/fastinflater/ViewCleanerTest.kt

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,4 +320,60 @@ class ViewCleanerTest {
320320
assertThat(parent.scaleX).isEqualTo(1f)
321321
assertThat(parent.rotation).isEqualTo(0f)
322322
}
323+
324+
// ── Visibility / Enabled / Selected / Activated / ContentDescription ──
325+
326+
@Test
327+
fun `clean resets visibility to VISIBLE`() {
328+
val view = View(context).apply { visibility = View.GONE }
329+
330+
ViewCleaner.clean(view)
331+
332+
assertThat(view.visibility).isEqualTo(View.VISIBLE)
333+
}
334+
335+
@Test
336+
fun `clean resets isEnabled to true`() {
337+
val view = View(context).apply { isEnabled = false }
338+
339+
ViewCleaner.clean(view)
340+
341+
assertThat(view.isEnabled).isTrue()
342+
}
343+
344+
@Test
345+
fun `clean resets isSelected to false`() {
346+
val view = View(context).apply { isSelected = true }
347+
348+
ViewCleaner.clean(view)
349+
350+
assertThat(view.isSelected).isFalse()
351+
}
352+
353+
@Test
354+
fun `clean resets isActivated to false`() {
355+
val view = View(context).apply { isActivated = true }
356+
357+
ViewCleaner.clean(view)
358+
359+
assertThat(view.isActivated).isFalse()
360+
}
361+
362+
@Test
363+
fun `clean clears contentDescription`() {
364+
val view = View(context).apply { contentDescription = "test desc" }
365+
366+
ViewCleaner.clean(view)
367+
368+
assertThat(view.contentDescription).isNull()
369+
}
370+
371+
@Test
372+
fun `clean resets CompoundButton isChecked to false`() {
373+
val checkBox = CheckBox(context).apply { isChecked = true }
374+
375+
ViewCleaner.clean(checkBox)
376+
377+
assertThat(checkBox.isChecked).isFalse()
378+
}
323379
}

0 commit comments

Comments
 (0)