1. 为什么养老场景的预约挂号,Flutter和OpenHarmony会走到一起
1.1 智慧养老App对“预约挂号”功能的独特要求
先说我做这个项目的背景吧。我们团队接了一个社区智慧养老平台,服务对象是60岁以上的老年人,终端设备除了手机,还有社区里部署的平板一体机和老人家里的智慧屏。业务方提的第一个核心模块就是预约挂号——让老人不用跑到医院排长队,在社区的终端上就能挂到号。
这个看似简单的需求,放到养老场景里其实很不简单。年轻人挂号可以自己操作,老人群体不一样,视力下降、手指不灵活、对手机交互不熟悉。我们当时梳理出来几个硬指标:正文字号最小不能低于16sp,关键按钮的点击热区不能小于44x44像素,整个挂号流程要控制在三次点击以内完成核心操作。这比普通App的交互要求严格得多。
另一个难点是终端碎片化。小区里的平板一体机是触屏,老人家里的智慧屏是遥控器焦点操作,老人子女手机上有鸿蒙系统、有安卓、也有苹果。传统的做法是每个平台拉一套原生开发团队,但养老项目的预算和周期撑不起这种人力投入。我们最后定了Flutter跨端方案,配合OpenHarmony系统做底层能力适配,一次编写、多端运行,这是整个技术决策的起点。
1.2 Flutter跨端能力与OpenHarmony生态的契合性
我最早接触OpenHarmony的时候,说实话它的应用生态还比较薄弱,三方库、成熟组件、开发文档的丰富程度都不如安卓和iOS。但它的系统能力是实打实的,分布式软总线、通知服务、权限管理、多设备协同,这些对智慧养老场景特别有价值。比如预约挂号成功后要给老人手机发本地通知提醒,或者老人的挂号信息需要同步到社区大屏上,这些都是OpenHarmony擅长的事。
问题在于,OpenHarmony自己的原生应用开发语言是ArkTS/ArkUI,这套技术栈目前只有HarmonyOS生态在用,你单独学一套的成本很高。而Flutter的Dart代码是跨端的,UI层一次写好,最后编译成不同的目标平台产物。Flutter的OpenHarmony适配引擎会把Flutter的Skia/Impeller渲染层对接到OpenHarmony的图形栈上,Dart代码本身不需要改动太多。
我在项目里最直接的感受是:Flutter的widget组件在OpenHarmony上渲染以后,视觉还原度比想象中好很多。Flutter自己负责布局和绘制,OpenHarmony只提供最底层的窗口、纹理和输入事件回调。所以你在安卓上长什么样子,在OpenHarmony上基本就是什么样子,只是部分涉及原生控件的能力需要走平台通道。
1.3 为什么不是原生开发,也不是纯Web方案
团队内部其实争论过一轮。原生开发的好处是系统能力调用直接,性能也稳,但我们同时要覆盖OpenHarmony手机、触屏一体机、智慧屏三套设备,每套设备还要考虑不同的屏幕尺寸和交互方式,原生方案意味着至少三套代码并行开发,排期直接炸掉。
纯Web方案的方案也被否掉了。预约挂号涉及大量的异步交互、状态变更、倒计时逻辑,Web页面在社区一体机那种配置不高的设备上,滚动和切换动画都会掉帧。而且养老场景经常有网络不稳定的情况,Web资源加载慢起来,老人就会以为页面卡死了。还有一个关键问题:挂号成功后需要读取设备上的通知能力和日历权限,Web页面在这块的权限边界很模糊。
Flutter的优势在于它既能做到UI层的统一,又能通过平台通道拿到OpenHarmony的原生能力,性能和权限都在可控范围内。Flutter 3.x之后默认启用Impeller渲染引擎,在图形绘制效率上比早期的Skia有明显提升,滚动列表的帧率稳定性足够支撑养老终端的低配硬件。
2. 环境准备与工程搭建:HarmonyOS下跑通Flutter的第一道坎
2.1 Flutter SDK与OpenHarmony SDK的版本配比
这块是我踩坑最多的地方,必须单独说一下。Flutter官方对Android和iOS的支持很完善,对OpenHarmony的支持是靠OpenHarmony SIG(特别兴趣小组)维护的flutter_flutter仓库分支和配套引擎来完成的。也就是说,你不能直接用flutter官方SDK去构建OpenHarmony应用,需要把Flutter SDK切到OpenHarmony适配分支。
我的版本组合是这样定的,大家参考的时候要以当时的最新版为准:
| 组件 | 版本 | 说明 |
|---|---|---|
| Flutter SDK | flutter/engine的OpenHarmony适配分支(对应Flutter 3.22+) | 需要从OpenHarmony的flutter_flutter仓拉取 |
| OpenHarmony SDK | API 10及以上 | 建议用API 11,权限模型和通知服务更完整 |
| DevEco Studio | 4.0及以上 | 用于编译OpenHarmony工程 |
| JDK | 17 | 太高的版本会触发Gradle插件兼容性问题 |
一个很关键的点:Flutter的OpenHarmony适配分支和官方Flutter版本号不是一一对应的,你在官方版本上写的代码不一定能在适配分支上编译通过。我当时把官方Flutter 3.22的代码切到OpenHarmony分支上,遇到一堆编译报错,最后老老实实重新拉了一个干净分支来开发。
2.2 创建Flutter OHOS工程的完整流程
工程创建方式也和官方流程不一样,这里直接给出我验证过的步骤。
第一步,拉取适配分支的Flutter SDK:
git clone https://gitee.com/openharmony-sig/flutter_flutter.git cd flutter_flutter git checkout master这里不建议用默认分支,需要切到对应的OpenHarmony适配标签,比如3.22.0-ohos-3.2之类的,具体看仓库里的release记录。
第二步,配置环境变量:
export PATH="$PWD/bin:$PATH" export ANDROID_HOME=/path/to/your/android-sdk export OHOS_SDK_HOME=/path/to/openharmony/sdk注意,OpenHarmony SDK的路径要单独配置,不能和Android SDK混在一起。DevEco Studio安装好后内置的SDK路径通常在/Applications/DevEco-Studio.app/Contents/sdk这种地方,需要把default/openharmony目录指给OHOS_SDK_HOME。
第三步,创建工程并添加OpenHarmony平台:
flutter create my_smart_care_app cd my_smart_care_app flutter pub add ohos然后需要在工程根目录执行一个适配脚本,生成ohos目录结构:
flutter create --platforms ohos .这一步非常关键,如果不执行,工程里不会生成OpenHarmony的自定义工程目录。生成之后,你会看到ohos目录,里面是标准的OpenHarmony工程结构,包含entry/src/main/ets等路径。
2.3 编译期踩坑:Gradle插件apply顺序引发的连锁错误
动态社区的开发环境里,大家搜索flutter项目编译问题时经常看到一个报错,说用apply命令式地应用Flutter的Gradle插件,会导致之后执行的第三方插件无法读取到flutter引擎的依赖配置。我在OpenHarmony工程里第一次把Flutter模块嵌入的时候就撞上了这个坑。
工程里的ohos/build.gradle里如果用apply plugin: 'org.openharmony.gradle.plugin'的方式导入,后来又用apply plugin: 'flutter'导入Flutter插件,两个插件的装载顺序会直接影响依赖解析。正确的做法是使用plugins {}代码块声明插件依赖,让Gradle自行管理加载顺序:
plugins { id 'org.openharmony.gradle.plugin' version '1.0.0' id 'flutter' }如果已经用了apply方式,你会看到类似“Could not resolve all task dependencies”的编译错误,定位到的还是compileDebugJavaWithJavac任务依赖不完整。这个问题在适配分支的issue列表里出现过很多次,根因就是Flutter插件被apply之后,无法向同工程的Java编译任务注入flutter.jar的依赖路径。
解决方式就是统一改用plugins {}语法,并把根工程的settings.gradle里加上插件仓库地址:
pluginManagement { repositories { maven { url 'https://mirrors.huaweicloud.com/repository/maven/' } gradlePluginPortal() } }真实的项目里,大家也不要急着升级最新版的Gradle和JDK组合,Flutter的OpenHarmony适配分支对Gradle版本是有兼容矩阵的,官方文档里会标一个推荐版本范围,超过了就会出现各种奇怪的symbol找不到和task依赖错误。
3. 预约挂号页面的完整UI实现:组件拆分与无障碍设计
3.1 从“科室-医生-时间”三级结构中拆分Widget层级
预约挂号页面的核心交互流程是:选科室、选医生、选时间槽、确认挂号。很多人一上来就把页面做成了一个大ListView,数据全都塞在同一个State里,状态一变整个页面就重建,性能差还容易出Bug。
我实际的拆分方式是:一个BookingPage负责整体组合,下面分成三个独立的Widget模块——DepartmentPicker、DoctorList、TimeSlotGrid,每个模块只接收自己的数据和回调,互不干扰。
class BookingPage extends StatelessWidget { final BookingCubit cubit; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text('预约挂号')), body: Column( children: [ DepartmentPicker( departments: cubit.state.departments, selectedId: cubit.state.selectedDeptId, onSelected: (deptId) => cubit.selectDepartment(deptId), ), Expanded( child: DoctorList( doctors: cubit.state.doctors, onSelect: (doctor) => cubit.selectDoctor(doctor), ), ), TimeSlotGrid( slots: cubit.state.timeSlots, selectedTime: cubit.state.selectedTime, onSelect: (time) => cubit.selectTime(time), ), ], ), ); } }科室选择这里用了横向滚动的ChoiceChip列表,每个chip的高度做到48像素,文字用16sp。这里有个适老化的隐性需求:老人的手指摩擦力小,横向滚动容易误触,所以我在DepartmentPicker外层包了一个Scrollbar,并且在滚动结束回调里做了一次自动吸附,保证停留位置的chip是完整的。
医生列表用ListView.builder,每条卡片高120像素,包含医生头像占位、姓名、职称、擅长简介和一个预约按钮。这里有个细节:预约按钮不能放在卡片的右上角那种小图标位,而是放在整张卡片的底部通栏位置,这样老人点击的时候不容易点偏。这就是前面说的“三次点击内完成核心操作”的交互设计支撑。
3.2 下拉刷新、骨架屏与PlatformView的适配
挂号信息是实时变化的,老人在页面停留时间长了,医生排班和时间槽信息可能已经过期,所以页面必须具备下拉刷新能力。Flutter自带的RefreshIndicator在OpenHarmony上的表现整体可用,但有几个适配点要注意。
一个是在OpenHarmony的触屏设备上,RefreshIndicator的默认触发高度是100像素,老人的拖动速度慢,经常拖不到触发阈值。我在代码里把triggerMode设置为RefreshIndicatorTriggerMode.onEdge,这样在列表边缘继续下拉就会触发刷新,不需要拉到固定高度。
另一个是骨架屏。挂号页面首次加载时服务端接口在跑,如果直接给一个空白页面,老人会以为机器坏了。我用了一个简单的占位方案,加载中显示灰色圆角矩形块,每个块都在呼吸闪烁。这个闪烁动画在低配一体机上要控制好,不能每个块都单独开一个AnimationController,否则垃圾回收压力很大。正确的做法是共用一个AnimationController,把透明度变化值传给所有占位块。
在预约挂号的确认页,我们还嵌入了一个医院位置地图。这里就涉及Flutter的PlatformView。Flutter本身不能直接渲染高德或百度的原生地图SDK,必须通过UiViews把原生View嵌入到Flutter的渲染树里。在OpenHarmony适配分支上,PlatformView的支持是由flutter_ohos引擎实现的,需要在Dart侧注册view type:
final mapView = PlatformViewLink( viewType: 'com.example.map_view', onCreate: (PlatformViewCreationParams params) { return PlatformViewsService.initSurfaceView( params.id, viewType: 'com.example.map_view', layoutDirection: TextDirection.ltr, ); }, );注意这里引擎版本在3.22之前对PlatformView用的是虚拟显示方案,会有一个明显的View区域黑边,升级到3.22适配分支以后改成了纹理合并方案,黑边消失,但会占用GPU纹理内存。在社区一体机上,地图页面打开时间长了,会有纹理内存释放不完全导致的花屏问题,所以在地图页面销毁时调用PlatformViewsService的相关清理接口是一个必要动作。
3.3 适老化交互:字号、热区与TalkBack
适老化不是一句“字体调大”就能解决的。我在这轮开发里专门把页面的语义标签和无障碍支持做了单独的一次技术评审,这里分享几个可落地的方法。
字号处理:全局设定文字缩放策略,用MediaQuery的textScaler属性把基础字号压到系统级1.3倍,但关键操作按钮上的文字不能再跟着系统放大,否则按钮热区会被顶破。比如“确认挂号”按钮上,我使用textScaler: TextScaler.noScaling固定文字大小,保证按钮在任何系统字号设置下都是完整通栏可点击的。
点击热区:用ConstraintBox把最小尺寸框定为44x44,即使内部元素视觉上只有20像素高,实际可点击范围也扩大到44像素。这是Material设计规范里的标准,但在养老场景下仍然是最容易漏掉的基本功。
无障碍服务:OpenHarmony的屏幕阅读器和TalkBack类似,靠的是语义树。Flutter的widget默认会把文本内容暴露给语义树,但像“医生排班表”这种复合组件,需要手动用Semantics包裹来告诉屏幕阅读器这是一个容器:
Semantics( container: true, label: '医生列表', child: DoctorList(...), )实际测试下来,OpenHarmony的无障碍服务对Flutter语义树的解析效率比对Android原生View低,所以语义节点的数量要控制,不要在每一个时间槽上单独挂语义标签,而是把整个TimeSlotGrid作为单一语义节点暴露,让用户聚焦后用左右滑动来切换条目。
4. 原生能力桥接:EventChannel实现挂号后的通知订阅
4.1 为什么预约挂号的“提交成功”需要原生通道
预约挂号成功之后,业务要求给用户发一个本地通知,提醒“您已成功预约,请提前30分钟到达医院”。同时,如果挂了明天的号,后天早上的提醒必须在指定时间弹出。
这里有个技术事实:Flutter的Dart层跑在UI线程上,它的定时器在应用进程被系统杀到后台之后是不可靠的,尤其不能在后台做准点通知。所以通知的调度必须交给OpenHarmony的提醒代理服务来实现。Dart和OpenHarmony原生层之间的通信,就需要用到平台通道。
Flutter和原生平台的通信有三种:MethodChannel(一次调用一次返回)、EventChannel(原生向Dart持续推送事件)、BasicMessageChannel(双向消息传递)。我们的场景是挂号成功后,原生侧需要订阅系统日历和通知调度的事件流,把“预约创建成功”和“提醒时间已到”这两个事件推送给Dart侧处理,这就用到了EventChannel。如果用MethodChannel也可以,但每次都要Dart侧主动拉一次,不够实时。
4.2 Dart侧与ArkTS侧EventChannel的完整实现
先看Dart侧,在挂号提交成功之后,注册一个事件流的监听:
import 'package:flutter/services.dart'; class AppointmentNotificationApi { static const _eventChannel = EventChannel( 'com.example.medical/appointment_notifications'); static Future<void> subscribeAppointmentEvents() async { _eventChannel.receiveBroadcastStream().listen( (event) { if (event is Map) { final type = event['type']; if (type == 'appointment_created') { // 刷新挂号列表 _refreshAppointments(); } else if (type == 'appointment_reminder') { // 弹提醒 _showReminderDialog(event['title']); } } }, onError: (error) { debugPrint('EventChannel error: $error'); }, ); } }然后看OpenHarmony原生侧。OpenHarmony应用的开发语言是ArkTS,在EntryAbility里或者对应页面的etx文件里,通过ohos.channel模块注册对应的EventChannel:
import { eventChannel } from '@ohos.channel'; import { BusinessError } from '@ohos.base'; const channelName = 'com.example.medical/appointment_notifications'; let eventChannelInstance = eventChannel.createEventChannel(channelName); eventChannelInstance.on('listen', (callback) => { // 原生侧可以向Dart侧推送事件 const event = { type: 'appointment_created', title: '您已成功预约心血管内科张医生的号', time: '2025-06-20 09:30' }; callback.send(JSON.stringify(event)); });这里要注意序列化格式。Flutter的StandardMessageCodec和OpenHarmony平台通道之间传递数据,默认支持基础类型和Map,但我们在ArkTS侧直接传入Date对象或者自定义类会解析失败。我踩过这个坑:在ArkTS侧把API_Date对象塞进event里,Dart侧收到的是一个解不开的二进制缓冲。解决方式是统一在原生侧先做JSON.stringify,Dart侧再用json.decode解析,这样最稳妥。
另外,如果希望在应用无UI界面时也能推送事件,需要使用OpenHarmony的Ability生命周期配合。EventChannel的事件发送依赖Connection连接,应用退到后台后连接不会被立刻回收,但被系统强制杀掉后事件就会丢失。因此真正的提醒不能只靠EventChannel做,还要在原生侧建立一个长效任务,把提醒时间写入系统的reminderAgentManager,由系统到点弹出。
4.3 权限与后台提醒的配置
OpenHarmony从API 10开始权限模型比较严格,通知权限和后台任务权限都需要在module.json5里声明。
{ "module": { "requestPermissions": [ { "name": "ohos.permission.NOTIFICATION_CONTROLLER", "reason": "需要发送挂号成功通知", "usedScene": { "abilities": ["EntryAbility"] } }, { "name": "ohos.permission.KEEP_BACKGROUND_RUNNING", "reason": "需要后台调度挂号提醒", "usedScene": { "abilities": ["EntryAbility"] } } ] } }注意NOTIFICATION_CONTROLLER是系统级权限,普通应用不一定能申请到。普通的本地通知权限实际上用的是ohos.permission.NOTIFICATION,这个权限在用户第一次触发订阅时弹窗让用户确认。在代码里要先调用notificationManager.isNotificationEnabled()检查用户是否授权,如果没授权需要引导到系统设置页。做养老App时这里要有心理准备,老人可能看不懂授权弹窗,最好的方案是应用安装引导页里直接用图示告诉用户“点允许,否则收不到挂号提醒”。
后台提醒的调度我使用的是reminderAgentManager的publishReminder接口,指定触发时间、通知标题和内容:
import { reminderAgentManager } from '@ohos.reminderAgentManager'; let reminder = { reminderType: reminderAgentManager.ReminderType.REMINDER_TYPE_TIMER, triggerTime: new Date('2025-06-20T09:00:00').getTime(), wantAgent: { pkgName: 'com.example.medical', abilityName: 'EntryAbility' }, title: '预约挂号提醒', content: '您预约的专家号将在30分钟后开始就诊,请及时前往医院。', notificationId: 1001 }; reminderAgentManager.publishReminder(reminder).then((id) => { console.log(`Reminder created: ${id}`); });这个方式绕过了应用进程存活与否的限制,即使App被杀了,系统到点后仍会拉起提醒。这才是真正可靠的后台提醒方案。
5. 状态管理与异步流程:预约提交时的防重复与回执处理
5.1 状态管理选型:Cubit在挂号场景中的实际问题
预约挂号页面的前端状态其实不多:当前选中的科室、当前显示的医生列表、当前选中的时间槽、当前提交状态。但状态之间的联动很烦人——选了科室要重新拉医生列表,选完医生要重新拉时间槽,提交之后要清除所有已选状态并跳转。
一开始我用了Provider,写起来快,但三个页面模块共享同一个状态源时会互相通知,因为ChangeNotifier只能整体广播,每次更新都导致所有Consumer重建。在医院列表页这种频繁交互的场景,实测帧率掉到40帧左右,虽然不影响功能,但在一体机上显得特别卡。
后来换成了Bloc的Cubit,它的核心优势是状态被拆成一个个不可变快照,每个页面模块可以只监听自己关心的那个状态字段。比如时间槽组件只监听timeSlots字段的变化,科室选择器变了不会触发它重建。
class BookingCubit extends Cubit<BookingState> { BookingCubit(this._repository) : super(BookingState.initial()); Future<void> selectDepartment(int deptId) async { emit(state.copyWith(selectedDeptId: deptId, doctors: [], timeSlots: [])); final doctors = await _repository.fetchDoctors(deptId); emit(state.copyWith(doctors: doctors)); } Future<void> selectDoctor(int doctorId) async { emit(state.copyWith(selectedDoctorId: doctorId, timeSlots: [])); final slots = await _repository.fetchTimeSlots(doctorId); emit(state.copyWith(timeSlots: slots)); } Future<void> submitBooking() async { emit(state.copyWith(submitting: true)); try { final result = await _repository.submitAppointment(state.toRequest()); emit(state.copyWith(submitting: false, bookingConfirmed: result)); } catch (e) { emit(state.copyWith(submitting: false, error: e.toString())); } } }这套状态机的核心逻辑就是每次切换上级选项,下级列表先清空再重新加载,避免旧数据残留。
5.2 Future的then回调与微任务队列:倒计时按钮为什么不会卡
预约挂号里一般会有一个“获取验证码”的按钮,点击后进入60秒倒计时。很多新手写法是await Future.delayed(Duration(seconds: 1))然后循环,这样会把每个秒级间隔都作为一个微任务插入事件队列,如果页面里同时有列表滚动动画,微任务积压就会造成卡顿。
Flutter里Future的then回调默认是放入微任务队列的,而Timer的回调是放入事件队列的。微任务会在当前事件循环中尽快执行,但如果在同帧内塞入了大量微任务,就会阻塞帧渲染。所以倒计时正确做法是用Timer.periodic:
Timer? _countdownTimer; int _secondsLeft = 60; void _startCountdown() { _countdownTimer?.cancel(); _secondsLeft = 60; _countdownTimer = Timer.periodic(Duration(seconds: 1), (timer) { setState(() { _secondsLeft--; if (_secondsLeft <= 0) { timer.cancel(); } }); }); }有一个更隐蔽的坑:submitBooking这个异步方法里,如果在await之后直接context.read<BookingCubit>(),要看当前widget是否还在树上。如果用户在提交过程中切走了页面,await回来时context已经解挂,调用read会抛异常。安全的做法是在Cubit内部gather状态,而不是在widget里await之后再重新读context。
5.3 提交挂号的幂等设计与失败重试
预约挂号的提交最怕重复。老年人的操作习惯是点了按钮之后如果没看到“提交成功”的提示,会下意识再点一次。如果后端没有做幂等控制,一次挂号请求发出两次,用户就会拿到两个号。
前端首先要做的是一层锁:提交过程中把按钮置为不可点击状态,同时把整个页面的Pointer事件拦截掉,防止连环点击。但光这样不够,因为网络超时后前端会释放锁,用户再次点击时,第一次请求可能还在服务端处理中,还是会形成两笔订单。
前后端协同的幂等方案是:在提交请求里带上一个requestId,前端每次进入确认页时生成一个UUID,服务端以requestId作为唯一键做去重。如果服务端发现相同requestId已经处理过,就直接返回上一次的处理结果,而不是重新创建挂号单。
class BookingRepository { Future<AppointmentResult> submitAppointment(BookingRequest request) async { final requestId = request.requestId ??= Uuid().v4(); try { return await _api.createAppointment(request); } catch (e) { // 网络超时后的重试 return _api.createAppointment(request, retryWithSameRequestId: true); } } }失败重试策略上我采用的方案是:第一次失败后不自动重试,而是弹出一个对话框问用户“网络异常,是否重新提交”。如果用户选择重试,再发第二次请求时仍带上同一个requestId。这样既避免用户以为失败而手动重进页面造成新单,又能可靠地拿到最终结果。
6. 实测记录与踩坑复盘:从开发机到真机验证的主要问题
6.1 编译期:Gradle插件apply顺序引起的连锁错误
前面提到过工程搭建阶段的apply问题,但在真机上又遇到了一次变体。我们当时在工程里集成了推送、地图等三四个第三方插件,每个插件都是一个Flutter package,它们内部各自有自己的pubspec.yaml声明。OpenHarmony适配分支解析这些插件时,如果主工程里用了apply方式加载Flutter插件,就会出现插件依赖循环错误,报错集中在:ohos:compileDebugKotlin任务。
解决这个过程花了差不多两天。根因是OpenHarmony工程使用hvigor构建系统,和Android的Gradle不完全一样,很多三方插件在编译OpenHarmony平台时需要走一个ohosPlugin的处理流程。如果Flutter插件在Gradle阶段没有被生成对应的转换产物,后续hvigor阶段就找不到依赖符号。
大家的建议是不要只盯着报错信息,先检查根目录下的.flutter-plugins-dependencies文件是否正常工作。这个文件里列出了所有被Flutter解析到的插件路径,如果某个插件在文件里缺失,说明它的Ohos支持没有被引擎识别,需要检查该插件是否提供了ohos目录。
6.2 运行期:OpenHarmony上的滚动卡顿与PlatformView纹理问题
预约挂号页面里的医生列表是一个长列表,再加上时间槽网格,滑动操作非常频繁。在开发机(Windows模拟器)上一切正常,但在真机上发现滚动明显掉帧。
排查后发现,问题出在时间槽网格里每个slot我都加了阴影,阴影使用了BoxShadow,数量一多就触发了Flutter的绘制层重建。在低端设备上,GPU纹理提交带宽有限,大量带阴影的小矩形会造成过绘制。我把每个slot改成纯色边框,去掉阴影,同时列表项用RepaintBoundary隔离重绘区域后,帧率稳定在50帧以上。
另一个问题是PlatformView纹理。地图页面从预约详情页返回时,偶尔会出现一小块黑色残留。这是因为PlatformView在OpenHarmony上的纹理生命周期没有在返回动画中被正确回收。我用了一个Workaround:在Navigation.push返回时延迟200毫秒再销毁地图页面实例,让返回动画先跑完,再把地图原生Surface摘除。实测下来花屏率从15%降到0。
6.3 逻辑期:切换科室后医生列表状态残留
上线前最严重的一个Bug是:用户先选了“心血管内科”,医生列表加载出来滚动到第20个位置;然后切到“骨科”,再切回来时,“心血管内科”的医生列表还停留在第20个位置,但数据已经刷新,列表跳到第1个位置,视觉上出现一帧“旧数据停留”的现象。
这个问题本质上是Flutter的ListView在收到新数据源后复用了旧的滚动偏移。Cubit里已经清空了doctors,但widget树上的ListView还没有重建,它的ScrollController还保存着旧的偏移值。修复方式是给DoctorList加一个PageStorageKey,并且切换科室时重建列表的key:
DoctorList( key: PageStorageKey('doctor_list_${cubit.state.selectedDeptId}'), doctors: cubit.state.doctors, )这样每个科室有自己的独立list实例,不会互相复用滚动位置。这里还要提醒一句:如果用了skeletal动画或者占位组件,重建时要把占位组件包在AnimatedSwitcher里,否则会出现一瞬间的白屏闪烁,老人会对这种闪烁特别敏感。
最后再分享一个实际体会:做养老场景的App,技术上的难点永远不在“实现功能”,而在“功能实现之后怎么让老人愿意用”。预约挂号模块我做了很多轮真机测试,每次让社区老人来试操作时都能发现新的交互问题。轮椅级的最优先需求是减少步骤,眼神不好的用户需要大字号、高对比度,手抖的用户需要足够的点击容差。这些需求不一定能通过某个技术面试题反映,但在代码里它们是通过每一个Semantics标签、每一个BoxConstraints约束和每一帧滚动性能实打实地雕琢出来的。这些经验希望对正在做智慧养老方向的朋友们有用。