老旧Android手机改造:DIY电动车仪表盘项目记录

最近入手了小牛F100电动车,骑行体验还不错。家里正好有台2014年的索尼T2 Ultra手机闲置,想着与其放着积灰不如改造一下,于是萌生了给电动车做个专用仪表盘的想法。周末抽空写了个简单的Android应用,把这个老平板变成了车载导航和信息中心。

项目背景

这台T2 Ultra配置其实挺老了:骁龙400处理器、2GB RAM、16GB存储,Android 5.0.1系统。不过屏幕还算大(6.4寸),骑车时查看导航很方便。做这个项目纯粹是因为:

  1. 不想让旧设备浪费
  2. 市面上的专用导航仪要么功能单一要么太贵
  3. 正好可以练习一下Android开发

技术实现

架构设计

整个应用结构比较简单:

  • 主界面:MainActivity
  • 天气服务:WeatherService
  • 数据模型:CurrentWeatherResponse

没有使用什么高级架构模式,就是最基础的面向对象设计。考虑到这个应用主要是自用,也没必要过度设计。

UI实现

UI设计采用了ConstraintLayout作为主布局。这个布局相比传统的嵌套布局性能更好,尤其是在界面复杂度增加的情况下:

// 布局层次
ConstraintLayout (根布局)
  ├── ConstraintLayout (顶部信息栏)
  │     ├── LinearLayout (时间天气容器)
  │     ├── TextView (提示文字)
  │     └── LinearLayout (速度显示容器)
  └── TableLayout (应用图标容器)

界面渲染优化方面,尽量减少了视图嵌套层次,用ViewGroup#invalidate()requestLayout()来控制重绘。

顶部信息栏定位了3个关键信息:时间、天气和速度,用的是左中右布局。中间那句”记得取下我,以免丢失”其实是个提醒(毕竟是贵重电子设备,容易被偷)。

应用图标区域是个3列网格布局,结构很简单:

<TableLayout
    android:id="@+id/appsContainer"
    android:layout_width="match_parent"
    android:layout_height="0dp"
    android:layout_marginTop="40dp"
    android:clickable="true"
    android:focusable="true"
    android:stretchColumns="0,1,2"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toBottomOf="@id/topBar" />

核心功能实现

时间显示

时间显示用的是标准的Java日期格式化,没什么特别的:

SimpleDateFormat sdf = new SimpleDateFormat("hh:mm a", Locale.getDefault());
DateFormatSymbols symbols = new DateFormatSymbols(Locale.getDefault());
symbols.setAmPmStrings(new String[]{"AM", "PM"});
sdf.setDateFormatSymbols(symbols);
String currentTime = sdf.format(new Date());
timeTextView.setText(currentTime);

问题是这样每秒钟都要创建新的SimpleDateFormat对象,效率不高。更好的做法应该是把它做成静态变量或者成员变量复用,但考虑到这个应用场景,这点性能损失可以接受。

天气数据获取

天气数据是通过OpenWeatherMap的API获取的。这里有个坑,因为是自用就直接硬编码了API Key和城市ID(汕头市ID:1795940)。实际上不是很好的做法,应该放配置文件。

private static final String WEATHER_URL =
    "https://api.openweathermap.org/data/2.5/weather?id=1795940&appid=b82222794e7ae3c31e69c00093261f04&lang=zh_cn&units=metric";

API调用用的是老式的AsyncTask,现在看来有点过时了,更现代的做法是用Retrofit+RxJava或者Kotlin协程。但是,考虑到这个应用运行在Android 5.0.1上,使用传统方法兼容性更好:

private class FetchCurrentWeatherTask extends AsyncTask<Void, Void, String> {
    // 实现细节...
}

天气数据缓存用的是SharedPreferences,将JSON字符串直接存储:

SharedPreferences prefs = getSharedPreferences("WeatherCache", MODE_PRIVATE);
SharedPreferences.Editor editor = prefs.edit();
Gson gson = new Gson();
String weatherJson = gson.toJson(weather);
editor.putString("last_weather", weatherJson);
editor.putLong("timestamp", System.currentTimeMillis());
editor.apply();

读取时同样用Gson反序列化:

SharedPreferences prefs = getSharedPreferences("WeatherCache", MODE_PRIVATE);
String weatherJson = prefs.getString("last_weather", null);
if (weatherJson != null) {
    Gson gson = new Gson();
    CurrentWeatherResponse weather = gson.fromJson(weatherJson, CurrentWeatherResponse.class);
    // 后续处理...
}

速度计算

速度计算依赖Android的LocationManager服务,利用GPS获取实时速度:

LocationRequest locationRequest = LocationRequest.create();
locationRequest.setPriority(LocationRequest.PRIORITY_HIGH_ACCURACY);
locationRequest.setInterval(1000);

fusedLocationClient.requestLocationUpdates(locationRequest, locationCallback, null);

速度获取后,需要从m/s转换为km/h:

float speedKmh = location.hasSpeed() ? location.getSpeed() * 3.6f : 0;
speedTextView.setText(String.valueOf(Math.round(speedKmh)));

有个问题是GPS定位在高楼区域或地下车库会很不准,理想情况应该增加惯性导航辅助定位,但时间关系就没实现。

应用加载

程序预定义了几个常用应用的包名:

private static final String[] TARGET_PACKAGES = {
    "com.autonavi.amapautolite",// 高德地图
    "com.sonyericsson.music",// 索尼音乐
    "com.sonyericsson.fmradio",//收音机
    "com.yqdscott.dashcam",// 行车记录仪
    "com.sonyericsson.video",// 图库
    "com.android.settings"  };// 系统设置

然后通过包管理器获取这些应用的图标和名称:

ApplicationInfo appInfo = pm.getApplicationInfo(packageName, 0);
String appName = pm.getApplicationLabel(appInfo).toString();
Drawable appIcon = pm.getApplicationIcon(packageName);

这里有个小技巧是添加了点击监听器,通过反射调用系统接口来启动目标应用:

appView.setOnClickListener(v -> {
    try {
        Intent intent = pm.getLaunchIntentForPackage(pkgName);
        if (intent != null) {
            intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
            startActivity(intent);
        }
    } catch (Exception e) {
        Toast.makeText(MainActivity.this, "无法启动应用", Toast.LENGTH_SHORT).show();
    }
});

遇到的坑

开发过程中遇到一些有趣的问题:

  1. 透明背景显示系统壁纸:最初想把应用做成半透明的,但直接设置背景透明度会有奇怪的渲染问题。最终解决方案是动态获取系统壁纸作为背景:
final WallpaperManager wallpaperManager = WallpaperManager.getInstance(this);
try {
    Drawable wallpaperDrawable = wallpaperManager.getDrawable();
    View rootView = findViewById(android.R.id.content);
    rootView.setBackground(wallpaperDrawable);
} catch (Exception e) {
    Log.e(TAG, "设置壁纸失败", e);
}

天气图标加载失败:OpenWeatherMap的天气图标加载一直失败,查了半天发现是https问题。老手机的SSL库太老了,不支持现代加密算法。解决方法是改用http协议:

String iconUrl = "http://openweathermap.org/img/wn/" + icon + ".png";
Glide.with(MainActivity.this).load(iconUrl).override(25, 25).into(weatherIcon);

应用回到桌面功能:由于是全屏应用,没有导航栏,需要一个方式返回系统桌面。解决方法是在速度显示区域添加了一个隐藏触发器 – 连续点击3次返回桌面:

private void handleSpeedTap() {
    long now = System.currentTimeMillis();
    if (now - lastTapTime > 2000) {
        speedTapCount = 1;
    } else {
        speedTapCount++;
    }
    lastTapTime = now;
    if (speedTapCount >= 3) {
        returnToOriginalLauncher();
        speedTapCount = 0;
    }
}

实际使用体验

这个DIY项目安装在车上后,实际效果出乎意料地好。索尼T2 Ultra的屏幕尺寸刚好,导航地图显示清晰。速度显示功能也比车辆仪表盘更直观(毕竟字体更大)。

不过也存在一些问题:

  1. 电池续航:老设备的电池早已不行,需要外接充电宝(最近会换一个新电池)
  2. 屏幕亮度:阳光下可视度不够理想
  3. 防水性:下雨天只能用防水袋套着,操作不便

另外,有个意外收获是,骑到不熟悉的地方,手机导航看小屏幕很累,而T2 Ultra大屏导航则舒服多了。

那么在哪能获取源代码呢?

YuQing-Ding/E-Bike-UI: Focus mode while the user is riding the E-Bike (Android Only)

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *