Skip to content

Feature: Support Synthetic AttributeSet #3

@donglua

Description

@donglua

Feature Request: 支持 Synthetic AttributeSet

功能概述

在生成代码中构造 synthetic AttributeSet 对象,将编译时提取的 XML 属性传递给 ViewFactory,以支持完全兼容依赖 AttributeSet 的第三方 inflater 生态。

动机

当前 LayoutX2C 1.3.0 的 ViewFactory 机制传入的 attrs 参数为 null,这导致:

  1. 第三方 inflater 无法无缝接入

    • 换肤框架(需要从 attrs 读取 app:skin_* 属性)
    • 自定义 inflater(强依赖 AttributeSet 解析逻辑)
    • 需要修改第三方库代码才能兼容,迁移成本高
  2. 自定义 View 的属性驱动逻辑失效

    class PriceView(context: Context, attrs: AttributeSet?) : View(context, attrs) {
        init {
            val a = context.obtainStyledAttributes(attrs, R.styleable.PriceView)
            textColor = a.getColor(R.styleable.PriceView_priceColor, Color.BLACK)
            format = a.getString(R.styleable.PriceView_priceFormat)
            a.recycle()
        }
    }

    attrs == null 时,所有自定义属性丢失

  3. 生成代码需要更多补偿逻辑

    • 当前需要为每个属性生成 setter 调用
    • 有 synthetic AttributeSet 后,View 构造时自动处理,代码更简洁

提议的解决方案

编译时

在 KSP processor 中:

  1. 提取 XML 标签的所有属性(name、namespace、value、type)
  2. 生成 SyntheticAttributeSet 实例化代码

运行时

实现 SyntheticAttributeSet 类,满足 AttributeSet 接口:

class SyntheticAttributeSet(
    private val attributes: Map<String, AttributeValue>
) : AttributeSet {
    
    data class AttributeValue(
        val namespace: String?,
        val name: String,
        val value: String,
        val resourceId: Int = 0
    )
    
    override fun getAttributeCount(): Int = attributes.size
    
    override fun getAttributeName(index: Int): String = 
        attributes.values.elementAt(index).name
    
    override fun getAttributeValue(namespace: String?, name: String): String? =
        attributes.values.firstOrNull { 
            it.namespace == namespace && it.name == name 
        }?.value
    
    override fun getAttributeResourceValue(namespace: String?, name: String, defaultValue: Int): Int {
        val attr = attributes.values.firstOrNull { 
            it.namespace == namespace && it.name == name 
        }
        return attr?.resourceId ?: defaultValue
    }
    
    // ... 实现其他 20+ 个方法
}

生成代码示例

// 编译前的 XML
<com.example.PriceView
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:textColor="@color/primary"
    app:priceColor="@color/red"
    app:priceFormat="%.2f" />

// 生成的代码
val attrs = SyntheticAttributeSet(
    mapOf(
        "layout_width" to AttributeValue("http://schemas.android.com/apk/res/android", "layout_width", "match_parent"),
        "layout_height" to AttributeValue("http://schemas.android.com/apk/res/android", "layout_height", "wrap_content"),
        "textColor" to AttributeValue("http://schemas.android.com/apk/res/android", "textColor", "@color/primary", R.color.primary),
        "priceColor" to AttributeValue("http://schemas.android.com/apk/res-auto", "priceColor", "@color/red", R.color.red),
        "priceFormat" to AttributeValue("http://schemas.android.com/apk/res-auto", "priceFormat", "%.2f")
    )
)

val view = ViewFactoryCompat.createView(context, "com.example.PriceView", attrs)
    ?: PriceView(context, attrs)

收益

1. 完全兼容现有 inflater 生态

  • 换肤框架、自定义 inflater 无需任何修改
  • 零迁移成本

2. 保留属性驱动的初始化逻辑

  • 自定义 View 的 init 块正常工作
  • 不需要生成额外的 setter 调用

3. 减少生成代码体积

  • 属性打包在 AttributeSet 里,而不是逐个 setter
  • 对于属性很多的 View,代码更简洁

4. 更接近原生 XML inflate 语义

  • 保持声明式配置,而非命令式设置
  • 行为更可预测

实现挑战

1. AttributeSet 接口方法众多

需要实现 20+ 个方法,包括:

  • getAttributeValue() / getAttributeName()
  • getAttributeResourceValue() / getAttributeIntValue() / getAttributeFloatValue()
  • getAttributeBooleanValue() / getAttributeUnsignedIntValue()
  • getStyleAttribute() / getIdAttribute() / getClassAttribute()

2. 资源引用解析

需要正确处理:

  • 直接资源引用:@color/xxx → 解析为 resource ID
  • 主题属性引用:?attr/xxx → 从当前主题解析
  • 颜色字面量:#FF0000 → 解析为 int 值
  • 尺寸单位:16dp / 14sp → 转换为像素

3. 命名空间处理

  • android:http://schemas.android.com/apk/res/android
  • app:http://schemas.android.com/apk/res-auto
  • 自定义包名 → http://schemas.android.com/apk/res/<package>

4. 性能考虑

  • 避免每次创建 View 都构造新的 Map
  • 考虑缓存/复用 AttributeSet 实例
  • 属性解析的开销

替代方案

方案 A:扩展 ViewFactory 签名

interface ViewFactory {
    fun createView(
        context: Context, 
        name: String, 
        attrs: AttributeSet?,
        // 新增:编译时提取的属性 Map
        xmlAttrs: Map<String, Any?>?
    ): View?
}

优点:简单直接,不需要完整实现 AttributeSet
缺点:第三方 inflater 仍需修改代码适配

方案 B:按需提供

添加配置选项,让用户选择是否生成 synthetic AttributeSet:

layoutX2C {
    enableSyntheticAttributeSet = true  // 默认 false
}

优点:兼顾性能和兼容性,按需开启
缺点:增加配置复杂度

方案 C:仅支持常用方法

先实现最常用的方法(getAttributeValue, getAttributeResourceValue 等),其他方法抛出 UnsupportedOperationException

优点:快速 MVP,覆盖 80% 场景
缺点:边界情况可能崩溃

实现计划

Phase 1: 基础实现

  • 实现 SyntheticAttributeSet 类,支持核心方法
  • 编译时提取属性并生成实例化代码
  • 单元测试覆盖基本场景

Phase 2: 资源解析

  • 支持 @color, @dimen, @string 等资源引用解析
  • 支持 ?attr 主题属性解析
  • 支持颜色/尺寸字面量解析

Phase 3: 性能优化

  • AttributeSet 实例缓存机制
  • 延迟解析策略
  • 性能基准测试

Phase 4: 配置化

  • 添加 Gradle 配置选项
  • 文档和示例
  • 迁移指南

相关 Issue

  • #xxx - ViewFactory receives null AttributeSet causing NPE

期望效果

实现后,换肤框架等依赖 AttributeSet 的 inflater 可以无缝接入:

// 业务代码无需修改
ViewFactoryRegistry.setViewFactory { context, name, attrs ->
    skinInflater.createView(context, name, attrs)  // attrs 不再是 null
}

所有自定义属性在 View 构造时就能正确读取,保持与原生 XML inflate 一致的行为。

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions