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渲染,很多时候得自己动手。

核心观点: CarUI库适合中控交互场景,仪表盘核心渲染建议走自定义View或SurfaceView。

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。背景、静态刻度、装饰线,用自定义View。两者配合使用,性能最优。

这里给个简单的代码骨架,展示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);
            }
        }
    }
}
注意: SurfaceView的Z轴层级默认在普通View之上。如果你需要在SurfaceView上面叠加一些浮层(比如警告图标),记得用setZOrderOnTop(false)配合SurfaceView的透明背景。

3.3 仪表盘专用布局容器:为什么不用LinearLayout?

好,聊完渲染,咱们说说布局。你可能会问:仪表盘不就是几个View叠在一起吗?用FrameLayout不就行了?

嗯,理论上可以。但实际项目中你会发现,仪表盘的布局有几个痛点:

  • 圆形裁剪: 仪表盘大多是圆形的,子View需要被裁剪成圆形区域。
  • 角度定位: 刻度、指针需要围绕圆心旋转,用setRotation()虽然能转,但旋转中心默认是View左上角,你得手动算偏移。
  • 性能开销: 如果每个刻度都是一个独立的View,那布局层次会非常深,测量和布局阶段耗时严重。

所以我建议,自己写一个专用的布局容器。比如继承ViewGroup,重写onMeasure()onLayout(),让所有子View都基于圆心和角度来定位。

我曾经在一个项目里,写了一个叫DashboardLayout的容器。它的核心逻辑很简单:

  1. onMeasure()里,把容器的宽高设为相等,保证是正方形。
  2. onLayout()里,遍历所有子View,根据它们携带的LayoutParams(比如角度、半径偏移量),计算出每个子View的左上角坐标。
  3. 开启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,看看底层到底是怎么把像素画到屏幕上的。到时候见。