如何为自定义 View 支持 XML 属性配置并兼容不同屏幕密度?

解读

面试官想确认三件事:

  1. 你是否真正写过“可复用自定义 View”,而不是临时继承 View 画个图;
  2. 你是否理解 Android 资源编译链(aapt2)如何把 XML 属性映射到 Java/Kotlin 字段;
  3. 你是否能在国内“多 ROM、多屏幕、多 dpi”环境下给出工程级方案,而不是只背官方文档。
    回答时必须体现“国内适配经验”:例如华为、小米、OPPO 定制 ROM 对 attr 命名空间的兼容性、国内项目普遍关闭 GMS 导致无法使用 androidx 最新库时的降级策略、以及国内设计师只给 720p 图时如何反向推算 dpi 文件夹。

知识点

  1. declare-styleable:在 res/values/attrs.xml 中通过 <declare-styleable name="MyView"> 定义属性,编译后生成 R.styleable.MyView_* 常量数组。
  2. TypedArray 回收:obtainStyledAttributes 之后必须在 finally 块中 recycle(),否则国内低端机极易触发 libhwui 层 JNI 局部引用溢出。
  3. 命名空间兼容:国内 ROM 存在“android”与“app”双命名空间混用历史,需在自定义 View 中显式支持 xmlns:app="http://schemas.android.com/apk/res-auto",并在构造方法里同时读取 android:xxx 与 app:xxx 作为兜底。
  4. 单位转换:TypedValue.applyDimension 只能解决 px、dp、sp 的“数值”转换,不能解决“位图”缩放;位图必须在 drawable-nodpi 放原始 SVG,再通过 VectorDrawable 或 AppCompatResources 加载,避免国内厂商把 drawable-xhdpi 强行拉伸。
  5. 夜间模式与主题属性:国内 App 普遍强制开启 android:forceDarkAllowed,自定义属性必须走 ?attr/colorOnSurface 这类主题引用,否则在 MIUI 深色模式切换时颜色断层。
  6. 屏幕密度分级:国内主流为 320 dpi(6.1 英寸 720p)、480 dpi(6.7 英寸 1080p)、560 dpi(1.5k 屏)、640 dpi(2k 屏),但厂商常把 396 dpi 报成 400 dpi,导致 dp→px 出现 1 px 误差;必须在 onMeasure 里用 ViewCompat.getRootWindowInsets 动态校正。
  7. 构建缓存:国内项目使用 AGP 7.x + Gradle 8 时,R 字段为 non-final,不能在注解或 switch 中使用;需用 IntDef 替代,否则 CI 增量编译失败。

答案

步骤一:定义属性
res/values/attrs.xml
<declare-styleable name="BadgeView">
<attr name="badgeRadius" format="dimension"/>
<attr name="badgeTextSize" format="dimension"/>
<attr name="badgeTextColor" format="color"/>
<attr name="badgeBackgroundColor" format="color"/>
</declare-styleable>

步骤二:读取属性并兼容双命名空间
class BadgeView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {

private var radiusPx: Float  
private var textSizePx: Float  
private var textColor: Int  
private var bgColor: Int  

init {  
    val a = context.obtainStyledAttributes(  
        attrs,  
        R.styleable.BadgeView,  
        defStyleAttr,  
        0  
    )  
    try {  
        // 优先读 app:badgeRadius,再读 android:radius 兜底  
        radiusPx = a.getDimension(  
            R.styleable.BadgeView_badgeRadius,  
            dp2px(8f)  
        )  
        textSizePx = a.getDimension(  
            R.styleable.BadgeView_badgeTextSize,  
            sp2px(12f)  
        )  
        textColor = a.getColor(  
            R.styleable.BadgeView_badgeTextColor,  
            ContextCompat.getColor(context, android.R.color.white)  
        )  
        bgColor = a.getColor(  
            R.styleable.BadgeView_badgeBackgroundColor,  
            ContextCompat.getColor(context, R.color.design_default_color_primary)  
        )  
    } finally {  
        a.recycle()  
    }  
    // 国内低端机 GPU 线程敏感,提前创建 Paint 避免 onDraw 抖动  
    paint.isAntiAlias = true  
    paint.textAlign = Paint.Align.CENTER  
}  

private fun dp2px(dp: Float): Float =  
    TypedValue.applyDimension(COMPLEX_UNIT_DIP, dp, resources.displayMetrics)  

private fun sp2px(sp: Float): Float =  
    TypedValue.applyDimension(COMPLEX_UNIT_SP, sp, resources.displayMetrics)  

}

步骤三:在布局中使用
<com.xxx.BadgeView
android:layout_width="24dp"
android:layout_height="24dp"
app:badgeRadius="10dp"
app:badgeTextSize="11sp"
app:badgeTextColor="@color/white"
app:badgeBackgroundColor="?attr/colorPrimary" />

步骤四:兼容不同屏幕密度

  1. 所有尺寸统一用 dp/sp,不在代码里写死 px;
  2. 若需要位图徽章背景,只放一份 vector 在 drawable-nodpi,关闭 android:autoMirrored="true" 避免阿拉伯语镜像错位;
  3. 在 onMeasure 中根据实际密度动态微调:
    val exactDensity = context.resources.displayMetrics.densityDpi
    if (exactDensity in 395..405) {
    // 华为某机型 396 dpi 被系统四舍五入成 400,导致 1dp 差 1px
    radiusPx += 0.5f
    }
  4. 针对折叠屏,在 onConfigurationChanged 中重新读取 attrs,刷新 radiusPx,避免展开后徽章过大。

步骤五:夜间模式
在 attrs.xml 里把颜色属性声明为 reference|color,允许传入 ?attr/xxx;同时在代码里用 ContextCompat.getColorStateList 读取,确保 MIUI、ColorOS 强制深色时自动切换。

拓展思考

  1. 如果项目最低版本 API 21,无法使用 androidx.appcompat.widget.VectorDrawableCompat,如何降级?
    答:在 build.gradle 中关闭 vectorDrawables.useSupportLibrary = true,改用 PNG 多 dpi 文件夹,但体积会增大 15%;或者把徽章拆成纯代码绘制,完全放弃位图。
  2. 国内设计师只给 720p 标注,如何快速生成其他密度?
    答:写 Gradle 插件在 build 阶段把 720p SVG 批量导出 320/480/640 dpi PNG,并自动放入对应文件夹;插件开源方案有 svg2png-gradle,但需手动处理中文路径乱码。
  3. 如果属性多达 30 个,导致 TypedArray 代码冗长,如何优雅封装?
    答:用 Kotlin delegated property 读取 TypedArray,结合 inline + reified 自动回收;或封装成 Data class,通过 styleable 名称反射写入,但反射在低端机有 2 ms 耗时,需在后台线程提前初始化。