3、仪表盘UI框架:CarUI库介绍、自定义View与SurfaceView、仪表盘专用布局容器
好,咱们进入第三章。这一章我打算聊聊仪表盘UI的骨架——也就是框架选型和底层渲染方案。说实话,做车载仪表盘和做手机App完全是两码事。手机App你卡个几帧,用户顶多骂一句“垃圾优化”。但仪表盘要是掉帧,那可不是用户体验问题,是安全问题。
所以这一章,我会把CarUI库、自定义View和SurfaceView的取舍,以及仪表盘专用的布局容器,一次性讲透。
3.1 CarUI库:Google给车机开发者准备的“半成品”
先说说CarUI库。这个库是Google在Android Automotive里推的一套UI组件。说白了,它帮你封装好了车机场景下最常用的一些控件——比如带触控反馈的按钮、列表、进度条等等。
我个人习惯是,能用CarUI的组件就尽量用。为什么?因为它已经帮你处理了车载环境下的焦点控制、触摸区域放大、以及驾驶安全相关的交互限制。举个例子,CarUI里的CarButton,默认就带了一个最小触摸区域48dp的限制。你想想看,要是你自己写一个Button,忘了设这个,用户在颠簸路面上点半天点不到,那体验得多糟糕。
不过要注意,CarUI库不是万能的。它主要面向信息娱乐系统,也就是中控屏那块。对于仪表盘这种实时性要求极高的场景,CarUI的组件往往太重了。我在项目中遇到过,直接用CarUI的CarProgressBar做转速表动画,结果在低端芯片上直接卡成PPT。嗯,这里要注意——仪表盘的UI渲染,很多时候得自己动手。
3.2 自定义View vs SurfaceView:一场关于渲染管线的抉择
好,接下来是重头戏。仪表盘上的指针、刻度、数字,到底用自定义View画,还是用SurfaceView画?这个问题我当年纠结了整整一周。
先看自定义View。它的本质是继承View类,重写onDraw()方法,用Canvas画东西。优点是简单、直接、和Android的UI体系无缝集成。你可以在里面用invalidate()触发重绘,系统会在下一个VSync信号到来时调用onDraw()。
但问题来了——onDraw()是在主线程执行的。你想想看,仪表盘上的转速指针每秒要刷新60次,如果每次刷新都在主线程里画,那主线程稍微有点别的活儿(比如处理触摸事件、布局计算),画面就会卡顿。
所以,什么时候用自定义View?我个人建议:静态或低频更新的元素,比如背景刻度、固定文字、Logo图标。这些画一次就够了,不需要频繁重绘。
再看SurfaceView。它有一个独立的Surface,可以在子线程里任意时间绘制。这意味着你可以在一个后台线程里,以60fps甚至120fps的频率疯狂画图,完全不影响主线程的响应。
我在做一款120km/h时速的仪表盘原型时,就吃过自定义View的亏。指针动画一跑起来,整个UI都跟着抖。后来换成SurfaceView,把绘制逻辑扔到子线程,问题瞬间解决。
这里给个简单的代码骨架,展示SurfaceView的基本用法:
public class DashboardSurfaceView extends SurfaceView implements SurfaceHolder.Callback {
private DrawThread drawThread;
public DashboardSurfaceView(Context context) {
super(context);
getHolder().addCallback(this);
}
@Override
public void surfaceCreated(SurfaceHolder holder) {
drawThread = new DrawThread(holder);
drawThread.setRunning(true);
drawThread.start();
}
@Override
public void surfaceDestroyed(SurfaceHolder holder) {
drawThread.setRunning(false);
drawThread.interrupt();
}
class DrawThread extends Thread {
private SurfaceHolder holder;
private boolean running;
DrawThread(SurfaceHolder holder) {
this.holder = holder;
}
void setRunning(boolean running) {
this.running = running;
}
@Override
public void run() {
while (running) {
Canvas canvas = holder.lockCanvas();
if (canvas != null) {
// 在这里画指针、画刻度、画数字
drawDashboard(canvas);
holder.unlockCanvasAndPost(canvas);
}
// 控制帧率,比如每16ms画一帧
SystemClock.sleep(16);
}
}
}
}
setZOrderOnTop(false)配合SurfaceView的透明背景。
3.3 仪表盘专用布局容器:为什么不用LinearLayout?
好,聊完渲染,咱们说说布局。你可能会问:仪表盘不就是几个View叠在一起吗?用FrameLayout不就行了?
嗯,理论上可以。但实际项目中你会发现,仪表盘的布局有几个痛点:
- 圆形裁剪: 仪表盘大多是圆形的,子View需要被裁剪成圆形区域。
- 角度定位: 刻度、指针需要围绕圆心旋转,用
setRotation()虽然能转,但旋转中心默认是View左上角,你得手动算偏移。 - 性能开销: 如果每个刻度都是一个独立的View,那布局层次会非常深,测量和布局阶段耗时严重。
所以我建议,自己写一个专用的布局容器。比如继承ViewGroup,重写onMeasure()和onLayout(),让所有子View都基于圆心和角度来定位。
我曾经在一个项目里,写了一个叫DashboardLayout的容器。它的核心逻辑很简单:
- 在
onMeasure()里,把容器的宽高设为相等,保证是正方形。 - 在
onLayout()里,遍历所有子View,根据它们携带的LayoutParams(比如角度、半径偏移量),计算出每个子View的左上角坐标。 - 开启
setClipChildren(true),配合setBackground画一个圆形遮罩,实现圆形裁剪。
这样,你在XML里就可以这样写:
<com.example.DashboardLayout
android:layout_width="300dp"
android:layout_height="300dp">
<!-- 背景刻度 -->
<com.example.ScaleView
app:angle="0"
app:radiusOffset="0dp" />
<!-- 指针 -->
<com.example.PointerView
app:angle="45"
app:radiusOffset="20dp" />
</com.example.DashboardLayout>
你看,这样布局语义就清晰多了。每个子View只需要关心自己的角度和半径,不用管父容器怎么摆。
onLayout()里直接调用了child.layout(),结果发现子View的onDraw()里拿到的宽高不对。后来才意识到,layout()之后必须调用child.measure()重新测量,否则子View的getWidth()还是0。嗯,这个坑我踩过,你们别踩了。
3.4 三者如何协同工作?
最后,我总结一下这三者的分工:
| 组件 | 职责 | 典型场景 |
|---|---|---|
| CarUI库 | 提供车机通用控件,处理交互安全 | 设置菜单、音量滑块、列表 |
| 自定义View | 绘制静态或低频更新的UI元素 | 背景刻度、品牌Logo、固定文字 |
| SurfaceView | 高频动态渲染,独立线程绘制 | 指针动画、数字跳变、动态光效 |
| 专用布局容器 | 管理子View的圆形定位与裁剪 | 仪表盘整体布局 |
说白了,CarUI库帮你搞定“能用”的部分,自定义View和SurfaceView帮你搞定“好看”的部分,而专用布局容器帮你搞定“好布局”的部分。三者缺一不可。
好,这一章就到这里。下一章我会深入讲讲仪表盘的绘制管线——从Canvas到OpenGL,看看底层到底是怎么把像素画到屏幕上的。到时候见。