简介:一份Android应用源码,实现汽车速度仪表盘风格的实时速度显示组件,常见于导航、骑行记录、运动健康或车辆模拟App。定位为源码参考,适合正在学习Android自定义View与图形动画的开发者,尤其对仪表盘类控件感兴趣的初、中级人员。压缩包仅337KB,共50个文件,包含5个java源文件、4个xml布局与配置、17个png界面资源,以及class、dex、apk等编译产物和使用说明txt,便于直接运行查看效果,并与源码一一对照。已有89人学习/下载,仍具参考价值。通过分析项目可掌握圆形仪表盘的核心实现:利用Canvas绘制刻度盘与指针,根据速度数据计算旋转角度,结合属性动画或传感器事件更新UI;同时涉及自定义View的测量布局、多分辨率资源适配、数据绑定与线程刷新等关键内容。工程结构清晰,整体适合作为Android图形编程的入门到进阶练习。
1. 速度仪表盘 android 应用源码:一包 zip 背后的采样、绘制与保活
“速度仪表盘 android 应用源码.zip”这类包,在很多资源站上都能搜到,下载量不低,但真正能在 android studio 里一次跑起来的没几个。原因不是代码质量差,而是多数演示源码把重点放在仪表盘 UI 上,测量网速的几件小事没说透:一是网速不是系统直接给的,应用层拿到的只是一对累计字节数;二是表盘指针角度不能随手画,显示范围和测量响应速度必须配合;三是悬浮窗加前台服务在 Android 8.0 以后有一堆权限门槛,解压后直接 build,大概率会卡在 Manifest 或者运行时权限上。下面直接按这条链路走一遍:从 TrafficStats 采样到 Canvas 绘图,再到真机悬浮窗运行,最后给出一组校验读数的技巧。带上 Android Studio 打开工程,跟着改会更顺手;这既适用想改成自己网速悬浮球的人,也适用想把同一套表盘换数据源改造成 CPU 或内存仪表盘的情况。
2. 用 TrafficStats 做速度采样:先拿到累计字节数,再换成每秒速率
2.1 为什么源码里读不到“当前网速”这个系统接口
Android 从 API 8 开始提供android.net.TrafficStats,这一组静态方法返回的都是设备启动以来的累计字节数,没有一个是“当前速率”。getTotalRxBytes()表示所有网络接口接收的字节总数,getTotalTxBytes()表示发送总量,getMobileRxBytes()与getMobileTxBytes()则只统计蜂窝网络收发。正因为这些是单调递增的计数器,速度仪表盘源码必须在自己的采样循环里连续取两次计数,把差值除以流逝的时间,才能得到 B/s 为单位的速度值。
这也解释了一个常见坑:直接把getTotalRxBytes()当成速度往下传给表盘,结果仪表永远显示一个天文数字或者负数。多数 zip 源码的实现顺序是“取数 → 差值 → 单位换算 → 平滑 → 设给 View”,如果拿到的包在第二步就直接赋值,基本可以判断这是一个为了展示控件而写的半成品。
2.2 采样核心代码:elapsedRealtime 计时,计数器差值除以真实毫秒数
常规做法是在一个前台服务的定时任务里放一个采样器对象,下面这段 Java 代码可以直接作为采样器骨架,测试时用 Handler 每 500ms 调一次poll()。
public class SpeedSampler { private long lastRxBytes; private long lastTxBytes; private long lastTimeMs; private long downloadSpeedBps; private long uploadSpeedBps; public void poll() { long rxBytes = TrafficStats.getTotalRxBytes(); long txBytes = TrafficStats.getTotalTxBytes(); long now = SystemClock.elapsedRealtime(); if (lastTimeMs > 0) { long dtMs = now - lastTimeMs; if (dtMs > 0) { // 差值 / 实际耗时,结果已是 B/s downloadSpeedBps = (rxBytes - lastRxBytes) * 1000L / dtMs; uploadSpeedBps = (txBytes - lastTxBytes) * 1000L / dtMs; // 网络切换、飞行模式会导致计数器重置,这里只保留非负值 if (downloadSpeedBps < 0) downloadSpeedBps = 0; if (uploadSpeedBps < 0) uploadSpeedBps = 0; } } lastRxBytes = rxBytes; lastTxBytes = txBytes; lastTimeMs = now; } public long getDownloadSpeedBps() { return downloadSpeedBps; } public long getUploadSpeedBps() { return uploadSpeedBps; } }代码里有两个地方值得细看。第一,时间是SystemClock.elapsedRealtime(),它取的是系统启动后的总毫秒数,不受用户改时间、NTP 自动校时影响;如果换成System.currentTimeMillis(),手机校时跳变上百毫秒,这一帧速率就会虚高或归零,这是速度仪表盘数值抖动的隐蔽来源。第二,公式先乘 1000L 再除 dtMs,用长整型避免小数,结果直接是 B/s;对 500ms 采样来说,把差值乘 1000 再除 500 是等效的。建议把这套采样逻辑独立成类,后续加平滑滤波或悬浮窗时,不需要反复改动读取流程。
2.3 采样参数的 3 个必调点:轮询间隔、速率单位、网络类型过滤
在把采样器接到 View 之前先把参数定下来,否则后面每步都在返工。下面这张表是这套源码里最常被改的几个位置,也对应引用工程里最容易写错的地方。
| 参数 | 推荐取值 | 说明 |
|---|---|---|
| 轮询间隔 | 500ms ~ 1000ms | 小于 300ms 毛刺明显,大于 2s 表盘反应迟钝 |
| UI 刷新间隔 | 与轮询一致或 250ms | 刷新不要高于采样频率,否则同一帧拿旧值重复绘制 |
| 显示单位 | B/s、KB/s、MB/s 自动切换 | 速率换算是 1000 进制,不是 1024 |
| 网络接口 | 总网速 / 仅 WiFi / 仅移动网络 | getTotalRxBytes包含全部接口,getMobileRxBytes才只统计蜂窝 |
单位换算要在传给仪表盘之前完成。通常速度仪表盘源码会用一个formatSpeed(long bps)方法做三档判断:小于 1024 显示 B/s,小于 1024 x 1024 显示 KB/s,否则显示 MB/s。这里进率一定要用 1000,网络速率是十进制单位,写反会造成数值系统性偏大 2.4%,够校准半天。网络类型过滤可加可不加,加的话需要监听ConnectivityManager回调,在 WiFi 断开时把lastRxBytes强制重置为当前值;因为网络切换后计数器可能回退,残留差值会造成一次瞬间大数值假象,重置以后再重新累计,仪表才不会出现“满格闪一下就归零”。
3. 仪表盘 View 用 Canvas 画:270 度表盘、刻度与指针角度换算
3.1 表盘坐标系:把起点放在 7 点半,270 度扫过整个环形轨道
速度仪表盘的传统形态是表针从 7 点半位置起,顺时针扫过 270 度到 4 点半位置停。Android 的Canvas.drawArc默认 0 度在三点钟方向,顺时针为正,所以常见写法是startAngle = 135f、sweepAngle = 270f。角度和速度值之间是线性关系:
指针角度 = 135 + 270 × (当前值 / 最大量程)
最大量程不建议写死成 10MB/s 或 100MB/s,因为 Wi-Fi 满速时可能落在几十 MB/s,空闲时只有几百 B/s,跨度太大,表针会常年贴在零刻度附近。实际项目里更稳妥的做法是让最大量程跟随最近一段时间的峰值自动调整,3.3 会给出具体策略。
3.2 onDraw 画轨道、刻度和表针:三个 Paint 对象就够
表盘绘制不复杂,轨道圆弧、刻度线、指针各用一个 Paint。下面代码是去除阴影渐变后的最小可用版,直接放进自定义 View 就能跑。
public class SpeedDialView extends View { private static final float START_ANGLE = 135f; private static final float SWEEP_ANGLE = 270f; private float maxValue = 10f * 1024f * 1024f; // 默认 10MB/s private float currentValue; private final Paint trackPaint = new Paint(Paint.ANTI_ALIAS_FLAG); private final Paint tickPaint = new Paint(Paint.ANTI_ALIAS_FLAG); private final Paint needlePaint = new Paint(Paint.ANTI_ALIAS_FLAG); public SpeedDialView(Context context) { super(context); trackPaint.setStyle(Paint.Style.STROKE); trackPaint.setStrokeWidth(dp2px(12)); trackPaint.setColor(0xFFCCCCCC); tickPaint.setStrokeWidth(dp2px(2)); tickPaint.setColor(0xFF555555); needlePaint.setStrokeWidth(dp2px(4)); needlePaint.setStrokeCap(Paint.Cap.ROUND); needlePaint.setColor(0xFFE53935); } public void updateSpeed(float bps) { currentValue = bps; invalidate(); } @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); float cx = getWidth() / 2f; float cy = getHeight() / 2f; float radius = Math.min(cx, cy) - dp2px(16); // 外圈轨道 RectF arcRect = new RectF(cx - radius, cy - radius, cx + radius, cy + radius); canvas.drawArc(arcRect, START_ANGLE, SWEEP_ANGLE, false, trackPaint); // 11 条刻度,主刻度更粗 for (int i = 0; i <= 10; i++) { float angle = START_ANGLE + SWEEP_ANGLE * i / 10f; boolean major = (i % 5 == 0); tickPaint.setStrokeWidth(dp2px(major ? 3 : 1.5f)); drawTick(canvas, cx, cy, radius, angle, major); } // 指针 float fraction = Math.max(0f, Math.min(1f, currentValue / maxValue)); float angle = START_ANGLE + SWEEP_ANGLE * fraction; drawNeedle(canvas, cx, cy, radius * 0.72f, angle); } private void drawTick(Canvas canvas, float cx, float cy, float radius, float angle, boolean major) { float rad = (float) Math.toRadians(angle); float inner = radius - dp2px(major ? 18 : 12); float cos = (float) Math.cos(rad); float sin = (float) Math.sin(rad); canvas.drawLine( cx + inner * cos, cy + inner * sin, cx + radius * 0.92f * cos, cy + radius * 0.92f * sin, tickPaint); } private void drawNeedle(Canvas canvas, float cx, float cy, float length, float angle) { canvas.save(); canvas.rotate(angle, cx, cy); canvas.drawLine(cx, cy, cx + length, cy, needlePaint); canvas.drawCircle(cx, cy, dp2px(6), needlePaint); canvas.restore(); } private float dp2px(float dp) { return dp * getResources().getDisplayMetrics().density; } }updateSpeed内部的invalidate()是关键,它通知系统下一帧重新走onDraw。这里不用postInvalidate,因为采样器运行在主线程 Handler 上,直接刷新即可。把刷新入口和采样入口分开,这套速度仪表盘源码才能保持低耦合:改采样频率只动SpeedSampler,改表盘样式只动SpeedDialView。
刻度细节上,主刻度 3dp、次刻度 1.5dp,10 个区间对应 10 段阶梯。若想更密,把i <= 10改成 20 即可,20 条刻度在 30fps 悬浮窗下没有性能压力,真正的开销在贝塞尔曲线和阴影模糊,不要在刻度数量上纠结。指针的绘制用了canvas.rotate(angle, cx, cy)后直接沿 X 轴画一条线,这条线会跟着角度从 7 点半方向扫到 4 点半方向,省去了手算三角函数定位终点的工作。
3.3 指针动画与最大量程自动缩放:平滑值才像仪表
网速是高频抖动信号,如果表针直接跳到目标值,视觉上就像坏了的秒表。源码里一般会在 View 层做一次补间插值,每次updateSpeed只让指针往目标角度走 15%~25% 的差值。最省事的写法是用ValueAnimator:
private void animateNeedleTo(float targetBps, int durationMs) { ValueAnimator animator = ValueAnimator.ofFloat(currentValue, targetBps); animator.setDuration(durationMs); animator.setInterpolator(new AccelerateDecelerateInterpolator()); animator.addUpdateListener(a -> { currentValue = (float) a.getAnimatedValue(); invalidate(); }); animator.start(); }动画时长建议和采样周期一致,取 500ms 到 600ms。表针角度变化超过 90 度时,说明速率出现骤变,此时动画时长要压到 300ms 以内,否则表盘看起来总在慢半拍追数字。
最大量程自动缩放是另一个容易忽略的细节。固定量程在 10MB/s 时,200KB/s 的下载速度表针只动 2%,基本贴着零刻度。靠谱的源码方案会维护一个maxValue字段,并按两条规则调整:当前值超过 maxValue 的 90% 时 maxValue 倍增值;当前值连续 5 个采样周期低于 maxValue 的 30% 时 maxValue 减半。倍增策略要比减半激进,避免网络抖动导致量程频繁切换。
4. 在真机上把源码跑起来:前台服务、悬浮窗权限与工程解压检查点
4.1 在一个前台服务里托管采样器和表盘:startForeground 要在 5 秒内调完
速度仪表盘要悬浮在别的应用上层显示,就必须让采样进程长期存活。Android 8.0 之后后台应用启动普通服务会被抛IllegalStateException,所以标准写法是startForegroundService(context, intent)配startForeground()。下面这个服务主体骨架,同时解释了为什么很多 zip 源码在 Android 12 上闪退:要么没配 NotificationChannel,要么 targetSdk 30 以下的老代码还在用启动普通服务的路径。
public class SpeedFloatService extends Service { private static final int NOTIFICATION_ID = 1001; private static final String CHANNEL_ID = "speed_dial_float"; private SpeedSampler sampler; private FloatWindowManager windowManager; private Handler handler; private Runnable tickRunnable; @Override public void onCreate() { super.onCreate(); createNotificationChannel(); Notification notification = buildNotification(); startForeground(NOTIFICATION_ID, notification); sampler = new SpeedSampler(); windowManager = new FloatWindowManager(this); handler = new Handler(Looper.getMainLooper()); tickRunnable = new Runnable() { @Override public void run() { sampler.poll(); windowManager.updateSpeed( sampler.getDownloadSpeedBps(), sampler.getUploadSpeedBps()); handler.postDelayed(this, 500L); } }; } @Override public int onStartCommand(Intent intent, int flags, int startId) { handler.post(tickRunnable); return START_STICKY; } @Override public void onDestroy() { handler.removeCallbacks(tickRunnable); windowManager.release(); super.onDestroy(); } private void createNotificationChannel() { NotificationChannel channel = new NotificationChannel( CHANNEL_ID, "仪表盘悬浮窗", NotificationManager.IMPORTANCE_LOW); NotificationManager manager = getSystemService(NotificationManager.class); manager.createNotificationChannel(channel); } private Notification buildNotification() { return new NotificationCompat.Builder(this, CHANNEL_ID) .setContentTitle("速度仪表盘正在运行") .setContentText("显示实时网速") .setSmallIcon(R.drawable.ic_stat_speed) .setOngoing(true) .build(); } }START_STICKY可以保证系统在内存充足时杀掉服务后尝试重建,但重建只走onStartCommand,不会重新初始化onCreate里的sampler和windowManager。所以源码里最好在每次onStartCommand做一次判空,为空就重新初始化,这是很多老源码出现空指针的高发位置。
4.2 悬浮窗权限和通知权限:Android 8、Android 13 两道门槛
悬浮窗形态有明确的分层权限要求。Android 8 之前只要在 Manifest 里写SYSTEM_ALERT_WINDOW;8.0 之后需要用户到设置页手动打开“显示在其他应用上层”,这个权限不能通过运行时对话框直接申请,必须跳转设置。跳转代码在 Activity 里只需要一句:
Intent intent = new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:" + getPackageName())); startActivity(intent);Android 13(targetSdk 33)以后又多一个坑:即使悬浮窗权限开了,前台服务通知如果被通知权限挡住,部分机型会把服务识别为“运行但不通知”,用户一滑最近任务就可能连带杀掉服务。所以源码里要额外检测POST_NOTIFICATIONS运行时权限,未授权时先弹系统对话框申请。不同 API 级别对这套源码的影响,可以按下面这张表排查:
| Android 版本 | 影响点 | 源码处理位置 |
|---|---|---|
| API 26+ | 后台执行限制 | 启动按钮里改用 startForegroundService |
| API 26+ | 通知必须要有 channel | createNotificationChannel 之后再 buildNotification |
| API 29+ | 前台服务类型属性 | service 上声明 android:foregroundServiceType="specialUse" |
| API 33+ | 通知权限运行时申请 | 申请 POST_NOTIFICATIONS 后再拉起通知 |
| API 34+ | 前台服务类型强制校验 | 声明 specialUse 并配置 PROPERTY_SPECIAL_USE_FGS_SUBTYPE |
4.3 zip 解压后第一个小时:gradle 版本、targetSdk 和包名检查
从网上拿到的 android 应用源码 zip,解压后往往不能直接 build,旧工程在 Android Studio 里打开会卡在 gradle 版本或 SDK 版本不匹配。先打开 project 级和 app 级两个build.gradle,确认compileSdk、minSdk、targetSdk三个值;老源码通常是 compileSdk 28 配 Gradle 3.x,新版 Studio 打开后会提示升级,逐条点升级比手动改可靠,升级完大多能编译。
再看AndroidManifest.xml里 service 声明有没有android:exported="false",targetSdk 31 以上缺少这个属性会安装直接失败。换包名时只改applicationId不够,还要同步改namespace和 Manifest 里的 package,否则资源索引会错乱。最后,如果悬浮窗实现用的是老的TYPE_PHONE窗口类型,在 Android 8.0 以上一定要改成TYPE_APPLICATION_OVERLAY,否则窗口根本弹不出来。
签名方面,日常测试用 Android Studio 自动签名即可。但想长期后台保活,部分国产 ROM 会识别 debug 签名并限制其后台行为,正式自用建议生成 release 签名后走gradlew assembleRelease安装。
5. 校准速度仪表盘:一阶低通滤波、零值自检和双段式刻度
5.1 表针抖动用一阶低通滤波压住,alpha 取 0.3 附近
采样周期 500ms、指针动画 500ms 的组合下,数值一般不算抖,但 Wi-Fi 波动剧烈时仍会出现数字来回跳一个量级的情况。常见做法是加一阶低通滤波,公式就一行:
emaSpeed = alpha * instantSpeed + (1f - alpha) * emaSpeed;alpha越大越跟随真实值,越小曲线越平。网速场景取 0.3 到 0.4 比较合适,既能压掉瞬时毛刺,又不会让满速下载的上升沿显得拖沓。低于 0.15 时表针反应明显变慢,你看到 4MB/s 时实际可能已经涨到 8MB/s;高于 0.6 时基本等于没滤波。
需要注意滤波要在采样器层做,不要在 UI 层做。如果SpeedDialView先收到瞬时值、显示层再做平滑,会多一层状态,后续想调参就要同时改两个文件。
5.2 用 adb 对照 /proc/net/dev,确认采样没有自娱自乐
速度仪表盘数值对不对,最客观的参照是内核网络统计。手机连电脑后执行:
adb shell cat /proc/net/dev输出里每个网络接口一行,wlan0行的 RX bytes 是累计接收字节数。隔 5 秒再执行一次,两次 RX bytes 差值除以 5,就是该接口在 5 秒窗口内的平均下载速度。拿它和仪表盘显示值对比,误差在 10% 以内说明采样逻辑准确;误差超过 30%,就要检查是不是把getMobileRxBytes和getTotalRxBytes混用了。
零值自检也值得做一遍:开启飞行模式、关掉后台同步,速度仪表盘应稳定显示 0 B/s。若还有 1KB/s 以上的残留,通常是系统进程的周期心跳流量,不是 bug,但要确认它没有被低通滤波放大成虚高的表盘值。
5.3 最后调一个容易被忽略的细节:低速段刻度要单独拉伸
多数速度仪表盘源码把刻度均匀画在 0 到 maxValue 之间,但网速分布是重尾的,平时 90% 时间跑在 0 到 1MB/s。当 maxValue 自动涨到 100MB/s 后,低速段只占表盘不到 1 度,观感就是“表针永远呆立不动”。处理方式是把小于 1MB/s 的那段拉伸到表盘前 40% 的范围,其余 60% 留给高速段:
float fraction; if (currentValue <= 1024f * 1024f) { fraction = 0.4f * currentValue / (1024f * 1024f); } else { fraction = 0.4f + 0.6f * (currentValue - 1024f * 1024f) / (maxValue - 1024f * 1024f); } float angle = START_ANGLE + SWEEP_ANGLE * fraction;这段双段式映射会显著提高低速段的仪表分辨率,代价是刻度不再是严格的线性坐标,好在速度仪表盘用户看的是快慢趋势而不是精确读数,非线性带来的观感收益远大于坐标失真。最后把刷新链路上的三个频率对一下:采样 500ms、动画 500ms、低通 alpha 0.35,这三个值成组出现时指针既不会抖也不会拖;要改就一起改,单独调一个参数,表盘要么开始小幅抖动,要么数值滞后一个采样周期,看上去就不再丝滑。
本文还有配套的精品资源,点击获取