Android适老化App开发全解:从UI到无障碍服务的实践方案
2026/9/14 14:29:39 网站建设 项目流程

简介:基于Android实现的老友老年人专用App源码,是一套面向老年用户群体的完整App实现,适用于Android开发工程师、产品经理及交互设计人员学习和参考。压缩包内共527个文件,大小约69.89MB,主要涵盖png/jpg界面素材、xml布局及配置、java核心业务逻辑、so动态库、jar依赖包,以及gradle构建脚本和少量说明文档,资源结构清晰,便于按功能模块阅读。目前已有175人学习下载。该项目体现了适老化设计思路,包含简洁大字体界面、紧急呼叫、健康监测、药品提醒、新闻阅读等典型功能,并涉及无障碍服务、GPS定位、传感器数据获取、AlarmManager定时任务、网络请求与SQLite存储等关键实现方式;同时还能看到针对中低端设备的内存管理、图片懒加载与模块化目录划分等工程实践。对想深入理解特定用户群体Android定制开发、提升应用体验优化能力的开发者,具有较强的参考价值。

1. 为什么适老化App不能只调大字体

很多团队接手适老化改造时,第一反应是把textSize从 14sp 改成 20sp,再把按钮宽度翻倍。实际在老年人群里,真正卡住操作的往往不是字号,而是信息密度、反馈延迟和误触后的恢复成本。老友App这套源码把这几件事拆成了独立模块:UI 层用 ConstraintLayout 控制大图标和大点击区域,功能层通过 AlarmManager 和 SensorManager 分别解决用药提醒与健康监测,网络层用 Retrofit、Gson 做新闻流,最后用 AccessibilityService 兜底视觉和听力损失。它不是 demo 级的调参工程,而是按一个可交付的 Android Studio 项目组织的,适合想找适老化开发蓝本、又不想从空项目堆权的开发者拆来看。

2. XML布局到ConstraintLayout:老友App的适老化UI拆解

2.1 先看res/layout里做了什么

一个标准的 Android 工程,布局文件在src/main/res/layout下。老友App的适老化设计不是把字体统一调大,而是把“信息层级”重排。主界面用ConstraintLayout作为根布局,它能用一条链把大按钮按比例拉伸,不会因为不同屏占比出现错位。适老化设计里有一条基线是“最小触摸目标 48dp”,但实际代码里按钮的height不会写成 48dp 固定值,而是用minHeightpadding组合,保证系统字体缩放后依然有足够点击区域。

<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent"> <Button android:id="@+id/btn_emergency" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginStart="24dp" android:layout_marginEnd="24dp" android:minHeight="64dp" android:textSize="22sp" android:backgroundTint="#D32F2F" app:layout_constraintTop_toTopOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" /> </androidx.constraintlayout.widget.ConstraintLayout>

宽度0dp配合左右约束,让按钮水平撑满;minHeight而不是height,是为了避免字体放大后内容顶破固定高度。backgroundTint用高饱和红色,紧急呼叫按钮在第一时间能被识别。左右用margin而不是直接贴边,也是为单手操作留出拇指活动空间。

属性常规工程习惯适老化场景建议原因
textSize14sp20sp 以上小字号在远距离阅读时辨识度低
minHeight未设置或 36dp56dp-64dp老年人手部精细动作退化,需要更大触碰区
点击区域仅图标整行可点减少误触和瞄准成本
颜色对比度随意文本和背景至少 4.5:1低对比度颜色在亮度不足时无法分辨

2.2 系统字体缩放与自适应TextView

老年人常自行在系统设置中调大字号。如果布局写死dp高度,就会出现文字溢出。常见做法是检查系统fontScale,但更好的方式是用dp定义间距、sp定义字号,并给关键控件设置autoSizeTextAppCompatTextView的自动缩放能力可以避免手动计算字号。

<androidx.appcompat.widget.AppCompatTextView android:layout_width="0dp" android:layout_height="wrap_content" android:text="新闻摘要" android:textSize="20sp" android:maxLines="3" android:ellipsize="end" app:autoSizeTextType="uniform" app:autoSizeMinTextSize="14sp" app:autoSizeMaxTextSize="24sp" />

autoSizeTextType设为uniform,文本过长时自动缩小而不是截断;autoSizeMinTextSizeautoSizeMaxTextSize限制范围,避免缩小到难以阅读。maxLinesellipsize配合,保证新闻列表摘要始终显示三行,列表不会因为某条长标题被撑乱。这里有一个坑:auto-size 只对AppCompatTextView生效,如果 TextView 在LinearLayout里加了layout_weight,缩放后仍可能盖住旁边元素。所以适合把需要自动缩放的文字放在独立约束中,不和其他控件共享宽度。

2.3 操作反馈和状态可见性

适老化 UI 不只是静态布局,还有“反馈”。源码里常见的是对按钮做状态选择器,按下时改变背景色,并在操作完成后用 Toast 或语音播报。比如紧急呼叫按钮按下后,先震动再弹确认框,防止误触。用Vibrator配合AlertDialog做延迟确认。

button.setOnClickListener(v -> { Vibrator vibrator = (Vibrator) getSystemService(VIBRATOR_SERVICE); if (vibrator != null && vibrator.hasVibrator()) { vibrator.vibrate(200); } new AlertDialog.Builder(this) .setTitle("确认呼叫紧急联系人?") .setPositiveButton("呼叫", null) .setNegativeButton("取消", null) .show(); });

先调用vibrate产生触感反馈,再弹确认框。200 毫秒是刚好让手指感觉到、又不会让系统觉得卡顿的时长。这里不用performHapticFeedback,是因为部分中低端设备对系统触感反馈做了裁剪,直接调Vibrator更可靠。确认框的按钮没写点击事件,实际项目中应绑定定位和短信逻辑;但即使到弹窗,也能看出这个功能把“防误触”放在第一优先级。

3. 紧急呼叫与药品提醒:AlarmManager与定位服务实战

3.1 用AlarmManager做用药提醒

药品提醒是适老化 App 的标志性功能。老友App在原生层用的是AlarmManager,而不是Handler或协程延时。因为AlarmManager是系统级闹钟服务,App 进程被回收后,已注册的闹钟仍会由系统唤醒并发送广播。实现上,先定义BroadcastReceiver接收闹钟事件:

public class ReminderReceiver extends BroadcastReceiver { @Override public void onReceive(Context context, Intent intent) { String medName = intent.getStringExtra("med_name"); NotificationManager nm = context.getSystemService(NotificationManager.class); NotificationChannel channel = new NotificationChannel("med", "用药提醒", NotificationManager.IMPORTANCE_HIGH); nm.createNotificationChannel(channel); Notification notification = new Notification.Builder(context, "med") .setContentTitle("记得服药") .setContentText(medName + " 时间到了") .setSmallIcon(R.drawable.ic_notify) .build(); nm.notify((int) System.currentTimeMillis(), notification); } }

然后在设置提醒时计算时间戳并注册:

Intent intent = new Intent(context, ReminderReceiver.class); intent.putExtra("med_name", "降压药"); PendingIntent pi = PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); AlarmManager am = context.getSystemService(AlarmManager.class); am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, targetTimeMillis, pi);

setExactAndAllowWhileIdle是 Android 6 后推荐的精确闹钟方式,即使在 Doze 模式也会触发。RTC_WAKEUP表示使用系统真实时间,时间到就唤醒闹钟。targetTimeMillis由用户设定时间通过Calendar计算,格式为毫秒时间戳。FLAG_IMMUTABLE是 Android 12 起PendingIntent必须携带的 flag,否则高版本会抛异常。需要留意的是,Android 12 及以上精确闹钟需要SCHEDULE_EXACT_ALARM权限,国内不少 ROM 会默认禁止,所以源码里通常还要做一次权限检查并引导用户手动打开。

权限用途适用版本
SCHEDULE_EXACT_ALARM触发精确闹钟Android 12+
POST_NOTIFICATIONS展示通知Android 13+
ACCESS_FINE_LOCATION获取当前位置Android 6+
SEND_SMS发送紧急短信Android 6+

3.2 紧急呼叫的定位与通讯链路

紧急呼叫按钮按下后,第一步是拿最后已知位置。之所以不直接依赖getLastKnownLocation,是因为 GPS 冷启动可能耗时数秒,对老人来说等不起。常见做法是同时启动requestSingleUpdategetLastKnownLocation,哪个先回来用哪个:

LocationManager lm = getSystemService(LocationManager.class); try { Location last = lm.getLastKnownLocation(LocationManager.GPS_PROVIDER); if (last != null) { sendEmergencySms(last); } lm.requestSingleUpdate(LocationManager.GPS_PROVIDER, location -> { if (location != null) sendEmergencySms(location); }, Looper.getMainLooper()); } catch (SecurityException e) { e.printStackTrace(); }

getLastKnownLocation返回的是最近一次定位结果,可能已经陈旧,所以不能作为唯一依据。requestSingleUpdate只回调一次,配合Looper.getMainLooper()可以在主线程直接更新 UI。sendEmergencySms内部用SmsManager发送,需要SEND_SMS权限,Android 10 后对后台发送短信有更严格限制,因此这个动作应放在用户主动点击的前台场景中。

考虑到老人出门后可能手机没信号,源码包文件列表里有IAgoraRtcEngine.hAgoraBase.h,说明这个工程预留了音视频通话能力。如果需要做视频呼叫,可以把 Agora RTC 初始化放到Application里,再在紧急呼叫时拉起视频通话。文件列表只有头文件没有实现代码,所以这部分只能作为扩展思路,不能直接照搬。

4. 健康监测的传感器接入与数据落库

4.1 用SensorManager接计步器

健康监测模块通过SensorManager读取系统传感器。计步器有两种选择:TYPE_STEP_COUNTERTYPE_STEP_DETECTOR。前者返回从上次开机以来的累计步数,适合直接显示;后者每次走一步回调一次,适合做实时激励。老友App这类应用多数用TYPE_STEP_COUNTER,因为不必自己累积计数,且省电。

SensorManager sm = (SensorManager) getSystemService(SENSOR_SERVICE); Sensor stepSensor = sm.getDefaultSensor(Sensor.TYPE_STEP_COUNTER); if (stepSensor == null) { return; } sm.registerListener(this, stepSensor, SensorManager.SENSOR_DELAY_NORMAL);

然后在回调中处理:

@Override public void onSensorChanged(SensorEvent event) { if (event.sensor.getType() == Sensor.TYPE_STEP_COUNTER) { int steps = (int) event.values[0]; if (baseSteps == 0) baseSteps = steps; currentSteps = steps - baseSteps; textViewSteps.setText(currentSteps + " 步"); } }

event.values[0]是自设备开机以来的步数总计数,不是本次变化量,所以必须记录第一次回调时的基准值baseSteps。如果用户重启手机,baseSteps 会重置,但记录的是重启后的基数,逻辑仍然成立。SENSOR_DELAY_NORMAL约 200ms 一次回调,对计步器足够;频率太高会加速耗电。

传感器类型事件回调频率适用场景
TYPE_STEP_COUNTER步数累计变化时计步、运动统计
TYPE_STEP_DETECTOR每走一步触发一次步频估计、实时激励
TYPE_HEART_RATE周期性心率数据健康监测
TYPE_ACCELEROMETER高频振动数据跌倒检测、手势识别

注意:心率传感器很多设备没有,接入前要用getPackageManager().hasSystemFeature(PackageManager.FEATURE_SENSOR_HEART_RATE)判断,否则在低端机上会直接抛异常。另外,onSensorChanged回调运行在传感器事件循环里,不能做数据库写入或字符串格式化,否则会拖慢所有传感器事件的派发。

4.2 SQLite做数据落库而不是SharedPreferences

健康数据是连续记录,SharedPreferences不适合存列表。老友App的做法是继承SQLiteOpenHelper,建一张健康记录表:

public class HealthDbHelper extends SQLiteOpenHelper { public static final String DB_NAME = "health.db"; public static final int DB_VERSION = 1; public HealthDbHelper(Context context) { super(context, DB_NAME, null, DB_VERSION); } @Override public void onCreate(SQLiteDatabase db) { db.execSQL("CREATE TABLE health_record (" + "id INTEGER PRIMARY KEY AUTOINCREMENT," + "date TEXT," + "steps INTEGER," + "heart_rate INTEGER)"); } }

写入时用ContentValues

SQLiteDatabase db = helper.getWritableDatabase(); ContentValues values = new ContentValues(); values.put("date", new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(new Date())); values.put("steps", currentSteps); values.put("heart_rate", lastHeartRate); db.insert("health_record", null, values); db.close();

表结构里所有字段都用非空约束,日期存TEXT而不是INTEGER,一方面方便直接排序,另一方面便于人眼排查。insert返回 -1 表示失败,可以在开发阶段用 Log 打印。注意db.close()要在确认没有未关闭的游标时调用,否则会抛IllegalStateException;规范做法是用 try-catch-finally,或者把openOrCreateDatabase放在单例里统一管理。

5. 新闻阅读模块的网络层拆解:Retrofit、Gson与Glide

5.1 接口定义与网络请求管线

新闻阅读需要从服务端拉取列表。老友App的工程用 Retrofit + OkHttp 这一主流方案。先定义接口:

public interface NewsApi { @GET("api/news/list") Call<NewsListResponse> getNews(@Query("page") int page, @Query("size") int size); }

然后构建 Retrofit 实例,并加上日志拦截器方便排查:

OkHttpClient client = new OkHttpClient.Builder() .addInterceptor(new HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BASIC)) .connectTimeout(10, TimeUnit.SECONDS) .build(); Gson gson = new GsonBuilder().setLenient().create(); Retrofit retrofit = new Retrofit.Builder() .baseUrl("https://example.com/") .client(client) .addConverterFactory(GsonConverterFactory.create(gson)) .build(); NewsApi api = retrofit.create(NewsApi.class);

baseUrl必须以/结尾,否则 Retrofit 会抛IllegalArgumentException。日志拦截器级别建议用BASIC,生产环境去掉,避免把用户请求内容打到日志里。setLenient让 Gson 解析容错更强,但也可能掩盖服务端的格式问题,调试时注意看原始响应。Call<NewsListResponse>是 Retrofit 的同步调用声明,适合在后台线程执行;如果源码包后续迁移到 Kotlin 协程,可以改成suspend fun,但当前工程是 Java 风格,所以保留Call

5.2 列表解析与图片懒加载

拿到响应后,用response.body()得到NewsListResponse。老友App里没有把解析逻辑堆在 Activity 中,而是单独封装了NewsRepository。核心解析如下:

NewsListResponse body = response.body(); if (body != null && body.getData() != null) { List<News> list = body.getData(); adapter.setNewsList(list); }

News字段用@SerializedName注解做映射:

public class News { @SerializedName("id") public long id; @SerializedName("title") public String title; @SerializedName("thumbnail") public String thumbnail; }

图片加载用 Glide,因为它是 Android 生态里对生命周期感知最好的图片库:

Glide.with(imageView.getContext()) .load(news.getThumbnail()) .placeholder(R.drawable.placeholder_image) .error(R.drawable.error_image) .centerCrop() .into(imageView);

with会绑定 Activity 或 Fragment 的生命周期,不可见时自动暂停加载,中低端机上能省不少 CPU。centerCrop让缩略图按比例裁剪居中,避免矩形图片把列表撑乱。placeholdererror分别对应加载中和失败状态,老年人看到“图裂”图标会以为 App 坏了,所以失败图也要做得柔和。

维度HttpURLConnectionOkHttp + RetrofitVolley
接口定义手写线程和 JSON注解式声明Request 队列
文件下载不支持支持流式下载支持但配置繁琐
生命周期管理自己管可绑定组件支持较弱
维护成本低,社区资料全已停止更新

老友App用 Retrofit 是合理的:项目需要精确定义接口、方便测试,而且资料多,后续换人接手成本低。

6. AccessibilityService与启动优化的进阶验证技巧

6.1 用AccessibilityService把UI变成语音反馈

无障碍服务配置在res/xml/accessibility_config.xml

<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android" android:accessibilityEventTypes="typeWindowContentChanged" android:accessibilityFeedbackType="feedbackSpoken" android:notificationTimeout="100" android:canRetrieveWindowContent="true" />

服务类:

public class ElderAccessibilityService extends AccessibilityService { @Override public void onAccessibilityEvent(AccessibilityEvent event) { if (event.getEventType() == AccessibilityEvent.TYPE_VIEW_CLICKED) { String text = event.getText().toString(); if (!text.isEmpty()) { speak(text); } } } private void speak(String text) { TextToSpeech tts = new TextToSpeech(this, status -> {}); tts.speak(text, TextToSpeech.QUEUE_FLUSH, null, "utt"); } }

每次视图被点击时,系统会把事件内容以文本形式放到event里,这里直接调TextToSpeech播报。注意TextToSpeech实例不能每次都 new,内存开销大,应该在服务创建时初始化并复用。accessibilityFeedbackType设为spoken,表示该服务以语音为主要反馈方式。真正上线时,还要在服务里维护一个机制识别“默认播报还是打断”,否则连续点击多个按钮会制造噪音。

无障碍事件类型触发时机适老化用途
TYPE_VIEW_CLICKED视图被点击播报按钮文案
TYPE_VIEW_TEXT_CHANGED文本变化读输入内容
TYPE_WINDOW_STATE_CHANGED窗口切换播报页面标题
TYPE_NOTIFICATION_STATE_CHANGED通知变化朗读提醒详情

6.2 启动耗时与内存排查

性能优化不能靠感觉。在老友App工程里跑一遍验证,最直接的是 adb 命令:

adb shell am start -W -n com.example.laoyou/.MainActivity

输出里的TotalTime就是启动耗时。中低端手机上能压到 800ms 以内就算不错。再看堆内存:

adb shell dumpsys meminfo com.example.laoyou

重点关注Java HeapNative Heap两行。如果 Java Heap 持续增长,说明有对象泄露。结合源码优先查SQLiteDatabase是否每操作都关闭,Glide 加载的图片是否有超大尺寸原图。还有一个容易忽略的点:onSensorChanged回调里不能做任何格式化或数据库操作,因为它运行在传感器事件循环,过重会导致 UI 掉帧。启动时还建议把Application里的初始化任务裁剪到onCreate之外,比如用post延迟执行,至少能让首帧不被阻塞。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询