如何为自定义 View 支持 XML 属性配置并兼容不同屏幕密度?
解读
面试官想确认三件事:
- 你是否真正写过“可复用自定义 View”,而不是临时继承 View 画个图;
- 你是否理解 Android 资源编译链(aapt2)如何把 XML 属性映射到 Java/Kotlin 字段;
- 你是否能在国内“多 ROM、多屏幕、多 dpi”环境下给出工程级方案,而不是只背官方文档。
回答时必须体现“国内适配经验”:例如华为、小米、OPPO 定制 ROM 对 attr 命名空间的兼容性、国内项目普遍关闭 GMS 导致无法使用 androidx 最新库时的降级策略、以及国内设计师只给 720p 图时如何反向推算 dpi 文件夹。
知识点
- declare-styleable:在 res/values/attrs.xml 中通过 <declare-styleable name="MyView"> 定义属性,编译后生成 R.styleable.MyView_* 常量数组。
- TypedArray 回收:obtainStyledAttributes 之后必须在 finally 块中 recycle(),否则国内低端机极易触发 libhwui 层 JNI 局部引用溢出。
- 命名空间兼容:国内 ROM 存在“android”与“app”双命名空间混用历史,需在自定义 View 中显式支持 xmlns:app="http://schemas.android.com/apk/res-auto",并在构造方法里同时读取 android:xxx 与 app:xxx 作为兜底。
- 单位转换:TypedValue.applyDimension 只能解决 px、dp、sp 的“数值”转换,不能解决“位图”缩放;位图必须在 drawable-nodpi 放原始 SVG,再通过 VectorDrawable 或 AppCompatResources 加载,避免国内厂商把 drawable-xhdpi 强行拉伸。
- 夜间模式与主题属性:国内 App 普遍强制开启 android:forceDarkAllowed,自定义属性必须走 ?attr/colorOnSurface 这类主题引用,否则在 MIUI 深色模式切换时颜色断层。
- 屏幕密度分级:国内主流为 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 动态校正。
- 构建缓存:国内项目使用 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" />
步骤四:兼容不同屏幕密度
- 所有尺寸统一用 dp/sp,不在代码里写死 px;
- 若需要位图徽章背景,只放一份 vector 在 drawable-nodpi,关闭 android:autoMirrored="true" 避免阿拉伯语镜像错位;
- 在 onMeasure 中根据实际密度动态微调:
val exactDensity = context.resources.displayMetrics.densityDpi
if (exactDensity in 395..405) {
// 华为某机型 396 dpi 被系统四舍五入成 400,导致 1dp 差 1px
radiusPx += 0.5f
} - 针对折叠屏,在 onConfigurationChanged 中重新读取 attrs,刷新 radiusPx,避免展开后徽章过大。
步骤五:夜间模式
在 attrs.xml 里把颜色属性声明为 reference|color,允许传入 ?attr/xxx;同时在代码里用 ContextCompat.getColorStateList 读取,确保 MIUI、ColorOS 强制深色时自动切换。
拓展思考
- 如果项目最低版本 API 21,无法使用 androidx.appcompat.widget.VectorDrawableCompat,如何降级?
答:在 build.gradle 中关闭 vectorDrawables.useSupportLibrary = true,改用 PNG 多 dpi 文件夹,但体积会增大 15%;或者把徽章拆成纯代码绘制,完全放弃位图。 - 国内设计师只给 720p 标注,如何快速生成其他密度?
答:写 Gradle 插件在 build 阶段把 720p SVG 批量导出 320/480/640 dpi PNG,并自动放入对应文件夹;插件开源方案有 svg2png-gradle,但需手动处理中文路径乱码。 - 如果属性多达 30 个,导致 TypedArray 代码冗长,如何优雅封装?
答:用 Kotlin delegated property 读取 TypedArray,结合 inline + reified 自动回收;或封装成 Data class,通过 styleable 名称反射写入,但反射在低端机有 2 ms 耗时,需在后台线程提前初始化。