最近入手了小牛F100电动车,骑行体验还不错。家里正好有台2014年的索尼T2 Ultra手机闲置,想着与其放着积灰不如改造一下,于是萌生了给电动车做个专用仪表盘的想法。周末抽空写了个简单的Android应用,把这个老平板变成了车载导航和信息中心。
项目背景
这台T2 Ultra配置其实挺老了:骁龙400处理器、2GB RAM、16GB存储,Android 5.0.1系统。不过屏幕还算大(6.4寸),骑车时查看导航很方便。做这个项目纯粹是因为:
- 不想让旧设备浪费
- 市面上的专用导航仪要么功能单一要么太贵
- 正好可以练习一下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();
}
});
遇到的坑
开发过程中遇到一些有趣的问题:
- 透明背景显示系统壁纸:最初想把应用做成半透明的,但直接设置背景透明度会有奇怪的渲染问题。最终解决方案是动态获取系统壁纸作为背景:
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的屏幕尺寸刚好,导航地图显示清晰。速度显示功能也比车辆仪表盘更直观(毕竟字体更大)。
不过也存在一些问题:
- 电池续航:老设备的电池早已不行,需要外接充电宝(最近会换一个新电池)
- 屏幕亮度:阳光下可视度不够理想
- 防水性:下雨天只能用防水袋套着,操作不便
另外,有个意外收获是,骑到不熟悉的地方,手机导航看小屏幕很累,而T2 Ultra大屏导航则舒服多了。
那么在哪能获取源代码呢?
YuQing-Ding/E-Bike-UI: Focus mode while the user is riding the E-Bike (Android Only)
Leave a Reply