Flutter+OpenHarmony智能家居开发Day5|设备远程控制全流程
本文是 Flutter+OpenHarmony 智能家居开发系列的 Day5 详解,聚焦设备远程控制这一核心功能,从 “接口设计→业务逻辑封装→UI 交互开发→鸿蒙跨终端适配” 全流程拆解,解决了网络延迟、状态错乱、多终端兼容三大核心问题。
Flutter+OpenHarmony智能家居开发Day5|设备远程控制全流程详解
欢迎加入开源鸿蒙跨平台社区: https://openharmonycrossplatform.csdn.net
大家好~ 上一篇Day4我们完成了设备列表的下拉刷新与上拉加载,成功实现了设备的基础展示功能——用户能看到自己的所有智能设备、分页加载更多设备,也能通过下拉刷新获取最新的设备列表。但对于智能家居APP来说,“展示”只是基础,“控制”才是核心灵魂!想象一下,你躺在沙发上,打开APP就能远程开关空调、调节灯光亮度、控制窗帘开合,这才是智能家居的便捷之处,也是我们整个项目从“能用”到“好用”的关键跨越。
今天我们聚焦Day5的核心开发内容:设备远程控制+状态乐观更新,这也是整个智能家居项目中最核心、最影响用户体验的功能之一。不同于Day4的列表交互,设备控制涉及数据层、业务层、UI层的全链路联动,还要处理网络延迟、并发控制、状态同步、离线适配等一系列细节问题,尤其是在OpenHarmony跨终端环境下(手机/平板/DAYU200开发板),还要解决鸿蒙专属的权限、触控、网络拦截等适配问题。
为了让大家能直接落地开发,避免踩坑,本文将做到“逐步拆解、逐行解析”,每一个步骤都讲清楚“为什么做、怎么做、可能出什么问题、怎么解决”,从接口设计、逻辑封装、UI交互,到全场景问题排查、OpenHarmony专属适配,再到多终端测试验证,每一个细节都不遗漏。同时,我们会补充大量工程化实践细节(比如并发锁、状态回滚、错误提示优化),让代码不仅能运行,还能经得起实际场景的考验。
本文适合Flutter+OpenHarmony跨平台开发学习者、智能家居APP开发者,无论你是新手还是有一定经验,都能从本文中获取可直接复用的代码、完整的问题解决方案,以及贴合实际开发的思路技巧。话不多说,我们正式开启Day5的详细开发之旅!
一、Day5核心目标(明确方向,不走弯路)
在正式开发前,我们先明确Day5的核心目标,所有开发工作都围绕这些目标展开,确保不偏离重点,同时覆盖用户体验和工程化要求:
- 对接后端设备控制API接口,设计标准化的控制接口,兼容空调、灯光、窗帘等多种设备类型的控制需求;
- 实现“乐观更新”机制——用户操作设备后,立即更新UI状态,无需等待网络请求返回,请求失败后自动回滚状态,解决网络延迟导致的用户体验卡顿问题;
- 开发设备控制交互组件,包括设备开关、空调温度调节滑块、灯光亮度调节器、窗帘开合度控制器,适配多终端触控体验;
- 优化离线设备交互:离线设备禁用控制功能,添加明确的离线提示,避免用户误操作,提升用户体验;
- 解决设备控制过程中的所有高频问题:并发控制(同一设备同时被多次控制导致状态错乱)、状态回滚异常、网络异常提示、控制参数校验、多终端适配等;
- 完成OpenHarmony专属适配:网络权限动态申请、鸿蒙后台网络请求拦截解决、开发板触控适配、多终端控制响应速度优化;
- 实现多终端测试验证:模拟器、OpenHarmony手机真机、DAYU200开发板,确保控制功能在所有终端正常运行,无异常、无卡顿;
- 规范代码结构,补充注释,确保代码可复用、可扩展,为后续Day6的设备搜索筛选、Day10的MQTT实时同步奠定基础。
二、前置准备(衔接前文,避免脱节)
Day5的开发基于Day3、Day4已完成的项目架构,在开始开发前,我们需要先确认前置环境和已有代码是否就绪,避免开发过程中出现衔接问题,这也是实际开发中非常重要的一步(很多新手踩坑都是因为忽略了前置准备,导致代码报错、逻辑脱节)。
2.1 已有架构确认(重点衔接)
我们先回顾一下Day3、Day4已完成的核心代码和架构,确保所有依赖和基础组件都已正确集成:
- 数据层:已完成BaseDevice、AirConditionerDevice、LightDevice、CurtainDevice等数据模型,使用json_serializable实现JSON解析;已封装HttpClient网络工具,支持GET、POST请求,实现了请求/响应拦截、异常处理;已封装DeviceRepository仓库,实现了设备列表的获取和缓存;
- 业务层:已封装DeviceProvider,使用Provider实现状态管理,管理设备列表数据、加载状态、错误信息,以及Day4新增的分页参数;
- UI层:已完成DeviceListPage设备列表页面、DeviceCard设备卡片组件,以及LoadingWidget、ErrorRetryWidget等通用组件,支持多终端布局适配;
- 依赖注入:已使用getIt实现依赖注入,注册了HttpClient、DeviceRepository等实例,降低代码耦合;
- 日志与异常:已集成logger日志工具,能打印详细的日志信息,便于问题排查。
2.2 依赖检查与补充(无遗漏)
Day5的开发不需要新增核心依赖,但需要确认已有依赖是否正确配置,避免出现依赖缺失导致的编译报错,同时补充1个辅助依赖(用于权限申请):
-
确认pubspec.yaml中已有依赖(沿用Day3、Day4配置):
dependencies: flutter: sdk: flutter dio: ^5.4.0 # 网络请求框架 json_annotation: ^4.8.1 # JSON解析注解 provider: ^6.1.1 # 状态管理 get_it: ^7.2.0 # 依赖注入 logger: ^1.1.0 # 日志工具 device_info_plus: ^9.1.0 # 设备信息获取(用于判断鸿蒙系统) flutter_windowmanager: ^0.2.0 # 窗口管理(适配多终端) dev_dependencies: flutter_test: sdk: flutter json_serializable: ^6.7.1 # JSON解析代码生成 build_runner: ^2.4.6 # 代码生成工具 flutter_lints: ^2.0.0 # 代码规范检查 -
补充权限申请相关依赖(用于OpenHarmony动态权限申请):
dependencies: permission_handler: ^11.0.1 # 动态权限申请(兼容鸿蒙) -
执行依赖安装命令,确保所有依赖都能正常加载:
flutter pub get【可能出现的问题1】:permission_handler依赖安装失败,提示“兼容问题”
【根因】:permission_handler部分版本与Flutter for OpenHarmony适配性不佳
【解决方案】:指定兼容版本,如上述配置的11.0.1,同时执行flutter clean清除缓存后,重新执行flutter pub get;若仍失败,可访问pub.dev查看该依赖的鸿蒙兼容版本,或替换为ohos_permission依赖(鸿蒙原生权限申请)。【可能出现的问题2】:dio依赖报错,提示“无网络权限”
【根因】:Day3配置的网络权限未生效,或鸿蒙侧未配置权限
【解决方案】:提前检查OpenHarmony侧的module.json5文件,确认已添加网络权限,后续会详细讲解。
2.3 后端接口准备(核心前提)
设备远程控制需要对接后端提供的控制API接口,我们先明确接口规范(本文使用模拟接口,实际开发中替换为真实接口即可),接口设计遵循RESTful规范,适配所有设备类型的控制需求:
- 接口地址:/device/control/{deviceId}(POST请求)
- 请求方式:POST
- 路径参数:deviceId(设备ID,用于定位具体设备)
- 请求体(JSON格式):
说明:请求体采用灵活配置,不同设备类型传递对应的特有参数,无需传递所有参数;后端会根据设备类型(device.type)校验参数合法性。{ "status": "on/off", // 设备开关状态,必填 "temperature": 25, // 空调特有参数,可选,单位℃(16-30) "mode": "cool", // 空调特有参数,可选,cool/heat/fan/dry/auto "fanSpeed": "medium", // 空调特有参数,可选,low/medium/high/auto "brightness": 80, // 灯光特有参数,可选,0-100 "color": "#FFFFFF", // 灯光特有参数,可选,十六进制颜色值 "isWarm": true, // 灯光特有参数,可选,true=暖光,false=冷光 "opening": 50, // 窗帘特有参数,可选,0-100(0=关闭,100=全开) "direction": "both" // 窗帘特有参数,可选,left/right/both } - 响应体(JSON格式,返回更新后的设备信息):
{ "id": "dev_123456", "name": "客厅空调", "type": "airConditioner", "status": "on", "online": true, "lastActiveTime": "2026-02-08 15:30:00", "iconUrl": "https://xxx.com/icons/air_conditioner.png", "temperature": 25, "mode": "cool", "fanSpeed": "medium" } - 状态码说明:
- 200:控制成功,返回更新后的设备信息;
- 400:参数错误(如温度超出范围、参数格式错误);
- 404:设备不存在;
- 500:后端服务器错误;
- 503:设备离线,无法控制。
2.4 开发环境确认(多终端适配前提)
确认开发环境已就绪,确保后续开发完成后能快速进行多终端测试:
- Flutter环境:Flutter 3.16.0+,已配置Flutter for OpenHarmony插件;
- 鸿蒙开发环境:DevEco Studio 4.0+,已配置鸿蒙SDK(API 10+);
- 测试设备:
- 模拟器:Mate 80 Pro Max(Android/鸿蒙模拟器);
- 真机:OpenHarmony 6.0+ 手机(如华为Mate 60 Pro鸿蒙版);
- 开发板:DAYU200开发板(已刷入鸿蒙系统,配置好调试环境);
- 调试工具:开启Flutter DevTools,便于调试状态变化、网络请求;开启logger日志打印,便于排查问题。
2.5 代码规范确认(工程化要求)
为了保证代码的可读性、可复用性和可扩展性,我们重申Day3、Day4制定的代码规范,Day5所有开发严格遵循:
- 文件夹命名:小写字母+下划线(如device_repository.dart);
- 类命名:大驼峰(如DeviceRepository、DeviceCard);
- 方法/变量命名:小驼峰(如toggleDeviceStatus、currentPage);
- 常量命名:全大写+下划线(如PAGE_SIZE、DEVICE_CONTROL_TIMEOUT);
- 代码注释:关键方法、核心逻辑、参数含义、问题解决方案都需要添加注释,便于后续维护和他人阅读;
- 分层架构:严格区分数据层(data)、业务层(domain)、UI层(presentation),不跨层调用,通过依赖注入实现层间通信;
- 异常处理:所有网络请求、数据转换、状态更新都需要添加try-catch捕获异常,避免APP闪退。
三、逐步骤详细开发(核心部分,每一步到位)
做好前置准备后,我们开始正式开发,Day5的开发分为4个核心模块,按“数据层→业务层→UI层→OpenHarmony适配”的顺序逐模块开发,每个模块分步骤拆解,每一步都讲清楚细节、问题和解决方案,确保大家能跟着步骤直接落地。
模块1:数据层扩展——设备控制接口封装(基础核心)
数据层的核心职责是对接后端接口、实现数据的获取与转换,与业务层解耦。Day5我们需要在原有DeviceRepository的基础上,扩展设备控制接口,实现控制API的调用、参数校验和数据转换,同时处理接口调用过程中的异常。
步骤1:设计控制接口(思路详解)
首先,我们需要在DeviceRepository抽象类中定义设备控制接口,接口设计需要遵循“通用性、灵活性、可扩展性”原则,适配所有设备类型的控制需求:
- 通用性:接口名称和核心参数统一,无论哪种设备,都通过同一个接口实现控制;
- 灵活性:请求参数采用Map<String, dynamic>格式,可灵活传递不同设备的特有参数,无需为每种设备单独定义接口;
- 可扩展性:接口返回值为BaseDevice类型,后续新增设备类型(如热水器、电视),无需修改接口定义,只需扩展BaseDevice子类即可。
同时,我们需要考虑接口的异常处理:接口调用超时、参数错误、设备离线、服务器错误等,都需要通过异常的形式传递给业务层,由业务层处理UI提示和状态回滚。
步骤2:扩展DeviceRepository抽象类(代码+详解)
文件路径:lib/domain/repositories/device_repository.dart
import 'package:dio/dio.dart';
import 'package:logger/logger.dart';
import 'package:smart_home_flutter/data/models/device_model.dart';
import 'package:smart_home_flutter/core/constants/cache_constants.dart';
import 'package:smart_home_flutter/core/exception/global_exception_handler.dart';
final Logger _logger = Logger();
// 仓库抽象类(便于后续扩展本地存储、Mock数据等实现)
abstract class DeviceRepository {
// 获取设备列表(Day4已实现,保留)
Future<List<BaseDevice>> getDeviceList({bool refresh = false, int page = 1, int size = 15});
// 清除缓存(Day4已实现,保留)
void clearCache();
// 新增:设备控制接口(核心接口)
/// 设备控制接口,适配所有设备类型
/// [deviceId]:设备ID,必填,用于定位具体设备
/// [params]:控制参数,必填,Map格式,包含status和对应设备的特有参数
/// 返回值:更新后的设备信息(BaseDevice子类实例)
/// 异常:抛出异常,包含错误信息,由业务层处理
Future<BaseDevice> controlDevice({
required String deviceId,
required Map<String, dynamic> params,
});
}
【代码详解】:
- 接口名称:controlDevice,简洁明了,符合语义;
- 参数说明:
- deviceId:必填参数,字符串类型,用于定位具体要控制的设备,确保控制的准确性;
- params:必填参数,Map<String, dynamic>类型,核心包含status(设备开关状态),以及对应设备的特有参数(如空调的temperature、灯光的brightness);
- 返回值:Future,异步返回更新后的设备信息,业务层可通过该返回值更新本地状态,确保与后端一致;
- 注释:详细说明接口功能、参数含义、返回值和异常情况,便于后续维护和调用。
【可能出现的问题1】:接口参数设计不合理,无法适配多种设备类型
【根因】:若为每种设备单独定义接口(如controlAirConditioner、controlLight),会导致代码冗余,后续新增设备类型需要修改抽象类,违反开闭原则;
【解决方案】:采用Map<String, dynamic>格式传递参数,统一接口定义,不同设备传递不同的特有参数,后端根据设备类型校验参数,前端无需关心后端的校验逻辑,只需传递正确的参数即可。
【可能出现的问题2】:返回值为具体子类(如AirConditionerDevice),无法通用
【根因】:若返回值为具体子类,接口通用性变差,业务层需要判断设备类型才能处理返回值;
【解决方案】:返回值为BaseDevice父类,业务层可通过类型转换(as)获取子类特有参数,同时保证接口的通用性和可扩展性。
步骤3:实现DeviceRepositoryImpl控制接口(代码+详解)
接下来,我们在DeviceRepositoryImpl实现类中,实现controlDevice接口,对接后端API,处理参数校验、网络请求、数据转换和异常捕获:
文件路径:lib/domain/repositories/device_repository_impl.dart
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:logger/logger.dart';
import 'package:smart_home_flutter/data/api/http_client.dart';
import 'package:smart_home_flutter/data/models/device_model.dart';
import 'package:smart_home_flutter/core/constants/cache_constants.dart';
import 'package:smart_home_flutter/core/constants/network_constants.dart';
import 'package:smart_home_flutter/core/exception/global_exception_handler.dart';
import 'package:smart_home_flutter/domain/repositories/device_repository.dart';
final Logger _logger = Logger();
// 设备仓库实现类(网络请求 + 内存缓存,Day4已实现,新增控制接口)
class DeviceRepositoryImpl implements DeviceRepository {
final HttpClient _httpClient;
List<BaseDevice>? _cacheDeviceList; // 内存缓存(Day4已实现)
DateTime? _cacheTime; // 缓存时间(Day4已实现)
// 构造函数注入HttpClient(依赖注入,降低耦合,Day4已实现)
DeviceRepositoryImpl({required HttpClient httpClient}) : _httpClient = httpClient;
// 获取设备列表(Day4已实现,保留,无需修改)
Future<List<BaseDevice>> getDeviceList({bool refresh = false, int page = 1, int size = 15}) async {
// 原有逻辑不变,此处省略,沿用Day4代码
}
// 清除缓存(Day4已实现,保留,无需修改)
void clearCache() {
_cacheDeviceList = null;
_cacheTime = null;
_logger.d('设备列表缓存已清除');
}
// 新增:实现设备控制接口(核心逻辑)
Future<BaseDevice> controlDevice({required String deviceId, required Map<String, dynamic> params}) async {
try {
// 步骤1:参数校验(避免无效请求,提升性能,减少后端压力)
_validateControlParams(deviceId, params);
// 步骤2:发起网络请求,调用后端控制接口
_logger.d('开始控制设备:deviceId=$deviceId,params=${json.encode(params)}');
final response = await _httpClient.post(
'${NetworkConstants.baseUrl}/device/control/$deviceId', // 拼接完整接口地址
data: params, // 请求体参数
options: Options(
sendTimeout: const Duration(milliseconds: NetworkConstants.sendTimeout), // 请求超时时间
receiveTimeout: const Duration(milliseconds: NetworkConstants.receiveTimeout), // 响应超时时间
headers: {
'Content-Type': 'application/json', // 指定请求体格式为JSON
},
),
);
// 步骤3:校验响应数据合法性
if (response == null || response is! Map<String, dynamic>) {
_logger.e('设备控制响应数据异常:response=$response');
throw Exception('控制失败,响应数据异常');
}
// 步骤4:将响应数据转换为BaseDevice子类实例(根据设备类型转换)
final deviceType = BaseDevice._deviceTypeFromJson(response['type'] ?? 'other');
BaseDevice updatedDevice;
switch (deviceType) {
case DeviceTypeEnum.airConditioner:
updatedDevice = AirConditionerDevice.fromJson(response);
break;
case DeviceTypeEnum.light:
updatedDevice = LightDevice.fromJson(response);
break;
case DeviceTypeEnum.curtain:
updatedDevice = CurtainDevice.fromJson(response);
break;
default:
updatedDevice = BaseDevice.fromJson(response);
}
// 步骤5:更新本地缓存(确保缓存数据与后端一致)
_updateCache(updatedDevice);
_logger.d('设备控制成功:deviceId=$deviceId,更新后状态=${updatedDevice.status.name}');
return updatedDevice;
} catch (e) {
// 步骤6:异常捕获与处理,打印日志,重新抛出异常(由业务层处理)
final errorMsg = GlobalExceptionHandler.getErrorMessage(e);
_logger.e('设备控制失败:deviceId=$deviceId,错误信息=$errorMsg,异常详情=$e');
// 区分不同异常类型,抛出更具体的异常,便于业务层处理
if (e is DioException) {
// 网络异常(超时、无网络、服务器错误等)
if (e.type == DioExceptionType.connectionTimeout || e.type == DioExceptionType.receiveTimeout) {
throw Exception('控制超时,请检查网络连接');
} else if (e.response?.statusCode == 503) {
throw Exception('设备已离线,无法控制');
} else if (e.response?.statusCode == 400) {
throw Exception('控制参数错误,请检查参数');
} else if (e.response?.statusCode == 404) {
throw Exception('设备不存在');
} else {
throw Exception('网络异常,控制失败');
}
} else {
// 其他异常(参数校验失败、数据转换失败等)
throw Exception(errorMsg);
}
}
}
// 新增:控制参数校验(辅助方法,私有,仅内部调用)
/// 校验设备控制参数的合法性,避免无效请求
/// [deviceId]:设备ID
/// [params]:控制参数
void _validateControlParams(String deviceId, Map<String, dynamic> params) {
// 1. 校验deviceId合法性(非空、非空白字符串)
if (deviceId.isEmpty || deviceId.trim().isEmpty) {
_logger.e('设备ID非法:deviceId=$deviceId');
throw Exception('设备ID不能为空');
}
// 2. 校验params非空
if (params.isEmpty) {
_logger.e('控制参数不能为空:deviceId=$deviceId');
throw Exception('控制参数不能为空');
}
// 3. 校验核心参数status(必须包含,且值为on/off)
final status = params['status'];
if (status == null || (status != 'on' && status != 'off')) {
_logger.e('控制参数错误:缺少status或status值非法,deviceId=$deviceId,status=$status');
throw Exception('控制参数错误,请指定正确的设备状态(on/off)');
}
// 4. 校验设备特有参数(根据实际业务需求,可扩展)
// 此处可根据设备类型,校验对应的特有参数(如空调温度范围16-30℃)
// 由于此时无法获取设备类型(需要先通过deviceId查询设备),暂不校验特有参数,由后端校验
// 后续可优化:先通过deviceId查询设备类型,再校验对应特有参数,减少无效请求
}
// 新增:更新本地缓存(辅助方法,私有,仅内部调用)
/// 设备控制成功后,更新本地缓存中的设备信息,确保缓存与后端一致
/// [updatedDevice]:更新后的设备信息
void _updateCache(BaseDevice updatedDevice) {
if (_cacheDeviceList == null || _cacheDeviceList!.isEmpty) return;
// 查找缓存中对应的设备索引
final index = _cacheDeviceList!.indexWhere((device) => device.id == updatedDevice.id);
if (index != -1) {
// 替换缓存中的设备信息
_cacheDeviceList![index] = updatedDevice;
_cacheTime = DateTime.now(); // 更新缓存时间
_logger.d('缓存更新成功:deviceId=${updatedDevice.id}');
}
}
}
【代码详解】:
- 核心逻辑拆分:controlDevice方法分为6个步骤,逻辑清晰,便于维护和排查问题;
- 参数校验(_validateControlParams):
- 校验deviceId:非空、非空白字符串,避免因设备ID非法导致的无效请求;
- 校验params:非空,避免空参数请求;
- 校验status:必须包含status参数,且值为on或off,确保核心控制参数正确;
- 可扩展:后续可优化,先通过deviceId查询设备类型,再校验对应设备的特有参数(如空调温度范围16-30℃),进一步减少无效请求;
- 网络请求:
- 拼接完整接口地址,使用NetworkConstants.baseUrl(全局常量,Day3已配置),便于后续修改接口地址;
- 设置请求超时时间(sendTimeout、receiveTimeout),避免因网络差导致的无限等待;
- 指定请求体格式为JSON,符合后端接口要求;
- 响应数据校验:校验响应数据是否为Map<String, dynamic>格式,避免因响应数据异常导致的数据转换失败;
- 数据转换:根据设备类型,将响应数据转换为对应的BaseDevice子类实例(如AirConditionerDevice、LightDevice),确保返回值的正确性;
- 缓存更新:控制成功后,更新本地缓存中的设备信息,确保缓存数据与后端一致,后续获取设备列表时能直接使用最新数据;
- 异常处理:
- 捕获所有异常,包括参数校验异常、网络异常、数据转换异常;
- 使用GlobalExceptionHandler.getErrorMessage(e)格式化错误信息,提升用户体验;
- 区分DioException(网络异常)和其他异常,针对不同的网络异常(超时、设备离线、参数错误)抛出具体的异常信息,便于业务层处理不同的错误提示。
【可能出现的问题1】:参数校验不严格,导致无效请求(如status传递为“open”)
【根因】:未校验status参数的具体值,仅校验了非空,导致传递非法参数给后端,增加后端压力;
【解决方案】:在_validateControlParams方法中,严格校验status参数的值,仅允许on和off,否则抛出异常,提前拦截无效请求。
【可能出现的问题2】:响应数据转换失败,提示“类型转换异常”
【根因】:后端返回的响应数据格式与前端数据模型不匹配,或设备类型转换错误;
【解决方案】:
- 增加响应数据校验,确保response是Map<String, dynamic>格式;
- 设备类型转换时,使用BaseDevice._deviceTypeFromJson方法,确保转换正确,同时处理异常情况(默认转换为other类型);
- 打印详细的响应数据日志,便于排查后端返回的数据格式问题。
【可能出现的问题3】:控制成功后,缓存未更新,导致后续获取设备列表时显示旧状态
【根因】:未找到缓存中对应的设备索引,或未更新缓存时间;
【解决方案】:
- 调试时打印缓存列表和设备ID,确认缓存中存在该设备;
- 确保index != -1时才更新缓存,避免数组越界;
- 更新缓存后,同步更新cacheTime,确保缓存的有效性。
【可能出现的问题4】:网络超时后,没有明确的异常提示,用户不知道原因
【根因】:未区分不同类型的DioException,仅抛出通用的网络异常;
【解决方案】:判断DioException的type,针对超时、无网络、服务器错误等不同情况,抛出具体的异常信息,如“控制超时,请检查网络连接”“设备已离线,无法控制”,便于业务层展示对应的错误提示。
步骤4:配置NetworkConstants全局常量(补充)
为了统一管理网络相关的常量(如接口地址、超时时间),我们补充NetworkConstants类,避免硬编码,便于后续修改:
文件路径:lib/core/constants/network_constants.dart
// 网络相关全局常量
class NetworkConstants {
// 基础接口地址(模拟地址,实际开发中替换为真实地址)
static const String baseUrl = 'https://api.smarthome-dev.com';
// 请求超时时间(毫秒)
static const int sendTimeout = 5000;
// 响应超时时间(毫秒)
static const int receiveTimeout = 5000;
// 设备控制接口路径
static const String controlDevicePath = '/device/control/';
// 设备列表接口路径
static const String deviceListPath = '/device/list';
}
步骤5:接口测试(关键步骤,避免后续踩坑)
接口封装完成后,我们需要进行接口测试,确保接口能正常调用、参数校验有效、异常处理正确,避免后续业务层开发时出现接口问题。测试方式采用“Mock接口测试+真实接口测试”结合:
-
Mock接口测试(推荐使用Postman):
- 新建Postman请求,地址设置为:https://api.smarthome-dev.com/device/control/dev_123456(替换为实际Mock地址);
- 请求方式设置为POST,请求体设置为:
{ "status": "on", "temperature": 25, "mode": "cool", "fanSpeed": "medium" } - 响应体设置为成功响应,模拟后端返回:
{ "id": "dev_123456", "name": "客厅空调", "type": "airConditioner", "status": "on", "online": true, "lastActiveTime": "2026-02-08 15:30:00", "iconUrl": "https://xxx.com/icons/air_conditioner.png", "temperature": 25, "mode": "cool", "fanSpeed": "medium" } - 调用DeviceRepositoryImpl的controlDevice方法,测试接口调用是否成功,缓存是否更新。
-
异常场景测试(重点):
- 测试场景1:deviceId为空,验证是否抛出“设备ID不能为空”异常;
- 测试场景2:params为空,验证是否抛出“控制参数不能为空”异常;
- 测试场景3:status传递为“open”,验证是否抛出“控制参数错误,请指定正确的设备状态”异常;
- 测试场景4:模拟网络超时,验证是否抛出“控制超时,请检查网络连接”异常;
- 测试场景5:模拟设备离线(后端返回503状态码),验证是否抛出“设备已离线,无法控制”异常;
- 测试场景6:模拟响应数据格式错误(返回数组),验证是否抛出“控制失败,响应数据异常”异常。
【可能出现的问题】:Mock接口测试成功,但真实接口调用失败
【根因】:真实接口地址、请求参数、请求头与Mock接口不一致,或后端接口未部署完成;
【解决方案】:
- 核对真实接口的地址、请求方式、参数格式、请求头,确保与前端代码一致;
- 联系后端开发人员,确认接口已部署完成,且能正常访问;
- 使用Postman测试真实接口,确保接口能正常返回数据,再进行前端调用。
模块2:业务层封装——控制逻辑+乐观更新(核心灵魂)
业务层的核心职责是处理业务逻辑、管理状态,对接数据层和UI层:接收UI层的用户操作(如点击开关、调节温度),调用数据层的控制接口,处理接口返回的结果,更新状态并通知UI层刷新。Day5的核心业务逻辑是“乐观更新+状态回滚”,这也是提升用户体验的关键。
步骤1:乐观更新原理详解(为什么需要乐观更新)
在智能家居APP中,设备控制的用户体验至关重要。如果采用“悲观更新”(等待网络请求返回后,再更新UI状态),当网络较差时,用户点击开关后,UI没有任何反应,需要等待1-5秒甚至更久,才能看到状态变化,用户体验极差,甚至会让用户误以为操作失败,重复点击。
而“乐观更新”的原理是:用户操作设备后,立即更新UI状态,无需等待网络请求返回;同时发起网络请求,若请求成功,用后端返回的最新数据覆盖本地状态;若请求失败,回滚到用户操作前的原始状态,并显示错误提示。
乐观更新的优势:
- 响应迅速:用户操作后立即看到状态变化,提升用户体验;
- 减少焦虑:避免用户因等待网络请求而产生焦虑,减少重复操作;
- 容错性强:请求失败后自动回滚状态,确保UI状态与设备实际状态一致,避免状态错乱。
乐观更新的核心注意点:
- 必须保存用户操作前的原始设备状态,用于请求失败后的回滚;
- 回滚逻辑必须可靠,确保回滚后状态与操作前完全一致;
- 错误提示必须及时、明确,让用户知道操作失败的原因;
- 避免并发操作:同一设备同时被多次控制,会导致状态回滚异常,需要添加并发锁。
步骤2:扩展DeviceProvider状态管理(核心逻辑,代码+详解)
我们在Day4已实现的DeviceProvider基础上,扩展控制相关的状态和方法,实现乐观更新、状态回滚、并发控制、错误提示等逻辑:
文件路径:lib/domain/providers/device_provider.dart
import 'package:flutter/foundation.dart';
import 'package:logger/logger.dart';
import 'package:smart_home_flutter/domain/repositories/device_repository.dart';
import 'package:smart_home_flutter/data/models/device_model.dart';
import 'package:smart_home_flutter/core/constants/error_constants.dart';
import 'package:smart_home_flutter/core/exception/global_exception_handler.dart';
final Logger _logger = Logger();
// 加载状态枚举(Day4已实现,保留)
enum LoadStatus {
initial, // 初始状态
loading, // 加载中
success, // 加载成功
failure, // 加载失败
}
class DeviceProvider with ChangeNotifier {
final DeviceRepository _deviceRepository;
// 原有状态变量(Day4已实现,保留)
LoadStatus _loadStatus = LoadStatus.initial;
List<BaseDevice> _deviceList = [];
String _errorMessage = '';
bool _isRefreshing = false;
bool _isLoadingMore = false;
final int _pageSize = 15;
int _currentPage = 1;
bool _hasMore = true;
// 新增:控制相关状态变量
Map<String, bool> _deviceLock = {}; // 设备并发锁,key=deviceId,value=是否锁定
bool _isControlling = false; // 全局控制状态,用于控制加载提示
// 对外暴露的只读状态(原有+新增)
LoadStatus get loadStatus => _loadStatus;
List<BaseDevice> get deviceList => _deviceList;
String get errorMessage => _errorMessage;
bool get isRefreshing => _isRefreshing;
bool get isLoadingMore => _isLoadingMore;
bool get hasMore => _hasMore;
bool get isControlling => _isControlling;
// 构造函数注入仓库(Day4已实现,保留)
DeviceProvider({required DeviceRepository deviceRepository})
: _deviceRepository = deviceRepository;
// 原有方法(Day4已实现,保留,无需修改)
Future<void> fetchDeviceList() async {}
Future<void> refreshDeviceList() async {}
Future<void> loadMoreDevices() async {}
void resetProvider() {}
void _setLoadStatus(LoadStatus status) {}
void _setErrorMessage(String message) {}
void _clearErrorMessage() {}
// 新增:设备控制核心方法(乐观更新+状态回滚+并发控制)
/// 设备控制核心方法,适配所有设备类型
/// [deviceId]:设备ID,必填
/// [targetStatus]:目标状态(on/off),必填
/// [extraParams]:额外控制参数(设备特有参数),可选
Future<void> toggleDeviceStatus({
required String deviceId,
required DeviceStatusEnum targetStatus,
Map<String, dynamic>? extraParams,
}) async {
// 步骤1:并发控制,避免同一设备同时被多次控制
if (_deviceLock[deviceId] == true) {
_logger.d('设备正在控制中,禁止重复操作:deviceId=$deviceId');
_setErrorMessage('操作过于频繁,请稍后再试');
return;
}
// 锁定该设备,禁止并发操作
_deviceLock[deviceId] = true;
// 设置全局控制状态,用于UI显示加载提示
_isControlling = true;
notifyListeners(); // 触发UI刷新,显示控制加载提示
// 步骤2:查找设备在列表中的索引,确认设备存在
final deviceIndex = _deviceList.indexWhere((device) => device.id == deviceId);
if (deviceIndex == -1) {
_logger.e('设备不存在,无法控制:deviceId=$deviceId');
_setErrorMessage('设备不存在,无法控制');
_deviceLock[deviceId] = false; // 释放锁
_isControlling = false;
notifyListeners();
return;
}
// 步骤3:保存原始设备状态(用于请求失败后回滚)
final originalDevice = _cloneDevice(_deviceList[deviceIndex]); // 深度克隆,避免引用传递导致回滚失败
_logger.d('保存原始设备状态:deviceId=$deviceId,原始状态=${originalDevice.status.name}');
try {
// 步骤4:乐观更新——立即修改UI状态,无需等待网络请求
_updateDeviceUIStatus(deviceIndex, targetStatus, extraParams);
notifyListeners(); // 触发UI刷新,显示更新后的状态
// 步骤5:发起网络请求,调用数据层的控制接口
_logger.d('发起设备控制请求:deviceId=$deviceId,目标状态=${targetStatus.name},额外参数=$extraParams');
final updatedDevice = await _deviceRepository.controlDevice(
deviceId: deviceId,
params: _buildControlParams(targetStatus, extraParams), // 构建请求参数
);
// 步骤6:请求成功——用后端返回的最新数据覆盖本地UI状态
_deviceList[deviceIndex] = updatedDevice;
_logger.d('设备控制成功,更新UI状态:deviceId=$deviceId');
_clearErrorMessage(); // 清除错误信息
} catch (e) {
// 步骤7:请求失败——回滚到原始状态,显示错误提示
_logger.e('设备控制失败,回滚原始状态:deviceId=$deviceId,错误信息=$e');
_deviceList[deviceIndex] = originalDevice; // 回滚状态
final errorMsg = GlobalExceptionHandler.getErrorMessage(e);
_setErrorMessage(errorMsg); // 设置错误信息
} finally {
// 步骤8:释放锁,重置控制状态,触发UI刷新
_deviceLock[deviceId] = false;
_isControlling = false;
notifyListeners(); // 触发UI刷新,显示回滚后的状态或错误提示
}
}
// 新增:辅助方法——构建控制请求参数
/// 构建后端接口所需的控制参数,将前端参数转换为后端要求的格式
/// [targetStatus]:目标状态(on/off)
/// [extraParams]:额外控制参数(设备特有参数)
Map<String, dynamic> _buildControlParams(DeviceStatusEnum targetStatus, Map<String, dynamic>? extraParams) {
final params = <String, dynamic>{
'status': targetStatus.name, // 核心参数:设备开关状态
};
// 追加额外参数(设备特有参数),避免空指针
if (extraParams != null && extraParams.isNotEmpty) {
params.addAll(extraParams);
}
return params;
}
// 新增:辅助方法——更新UI状态(乐观更新核心)
/// 乐观更新时,立即修改UI中的设备状态,无需等待网络请求
/// [deviceIndex]:设备在列表中的索引
/// [targetStatus]:目标状态
/// [extraParams]:额外控制参数(用于更新设备特有参数的UI显示)
void _updateDeviceUIStatus(int deviceIndex, DeviceStatusEnum targetStatus, Map<String, dynamic>? extraParams) {
if (deviceIndex < 0 || deviceIndex >= _deviceList.length) return;
final currentDevice = _deviceList[deviceIndex];
BaseDevice updatedDevice;
// 根据设备类型,更新对应的特有参数(UI显示用)
if (currentDevice is AirConditionerDevice && extraParams != null) {
// 空调设备:更新温度、模式、风速
updatedDevice = AirConditionerDevice(
id: currentDevice.id,
name: currentDevice.name,
type: currentDevice.type,
status: targetStatus,
online: currentDevice.online,
lastActiveTime: currentDevice.lastActiveTime,
iconUrl: currentDevice.iconUrl,
temperature: extraParams['temperature'] ?? currentDevice.temperature,
mode: extraParams['mode'] ?? currentDevice.mode,
fanSpeed: extraParams['fanSpeed'] ?? currentDevice.fanSpeed,
);
} else if (currentDevice is LightDevice && extraParams != null) {
// 灯光设备:更新亮度、颜色、暖光状态
updatedDevice = LightDevice(
id: currentDevice.id,
name: currentDevice.name,
type: currentDevice.type,
status: targetStatus,
online: currentDevice.online,
lastActiveTime: currentDevice.lastActiveTime,
iconUrl: currentDevice.iconUrl,
brightness: extraParams['brightness'] ?? currentDevice.brightness,
color: extraParams['color'] ?? currentDevice.color,
isWarm: extraParams['isWarm'] ?? currentDevice.isWarm,
);
} else if (currentDevice is CurtainDevice && extraParams != null) {
// 窗帘设备:更新开合度、方向
updatedDevice = CurtainDevice(
id: currentDevice.id,
name: currentDevice.name,
type: currentDevice.type,
status: targetStatus,
online: currentDevice.online,
lastActiveTime: currentDevice.lastActiveTime,
iconUrl: currentDevice.iconUrl,
opening: extraParams['opening'] ?? currentDevice.opening,
direction: extraParams['direction'] ?? currentDevice.direction,
);
} else {
// 其他设备:仅更新开关状态
updatedDevice = BaseDevice(
id: currentDevice.id,
name: currentDevice.name,
type: currentDevice.type,
status: targetStatus,
online: currentDevice.online,
lastActiveTime: currentDevice.lastActiveTime,
iconUrl: currentDevice.iconUrl,
);
}
// 更新列表中的设备状态(UI显示)
_deviceList[deviceIndex] = updatedDevice;
_logger.d('乐观更新UI状态成功:deviceId=${currentDevice.id},目标状态=${targetStatus.name}');
}
// 新增:辅助方法——深度克隆设备对象(关键,避免回滚失败)
/// 深度克隆设备对象,避免引用传递导致的回滚失败
/// 原因:如果直接赋值(originalDevice = _deviceList[deviceIndex]),则originalDevice与列表中的设备引用同一个对象,
/// 乐观更新时修改列表中的设备,会同时修改originalDevice,导致回滚时无法恢复到原始状态
BaseDevice _cloneDevice(BaseDevice device) {
if (device is AirConditionerDevice) {
return AirConditionerDevice(
id: device.id,
name: device.name,
type: device.type,
status: device.status,
online: device.online,
lastActiveTime: device.lastActiveTime,
iconUrl: device.iconUrl,
temperature: device.temperature,
mode: device.mode,
fanSpeed: device.fanSpeed,
);
} else if (device is LightDevice) {
return LightDevice(
id: device.id,
name: device.name,
type: device.type,
status: device.status,
online: device.online,
lastActiveTime: device.lastActiveTime,
iconUrl: device.iconUrl,
brightness: device.brightness,
color: device.color,
isWarm: device.isWarm,
);
} else if (device is CurtainDevice) {
return CurtainDevice(
id: device.id,
name: device.name,
type: device.type,
status: device.status,
online: device.online,
lastActiveTime: device.lastActiveTime,
iconUrl: device.iconUrl,
opening: device.opening,
direction: device.direction,
);
} else {
return BaseDevice(
id: device.id,
name: device.name,
type: device.type,
status: device.status,
online: device.online,
lastActiveTime: device.lastActiveTime,
iconUrl: device.iconUrl,
);
}
}
// 新增:单独控制设备特有参数(如空调调节温度,不改变开关状态)
/// 单独控制设备特有参数,不改变开关状态(如空调调节温度、灯光调节亮度)
/// [device# 续:Flutter+OpenHarmony智能家居开发Day5|设备远程控制全流程详解(乐观更新+鸿蒙适配+万行细节版)
### (接上文DeviceProvider部分)
#### 步骤2续:补全DeviceProvider剩余核心方法(重要代码+详解)
```dart
// 新增:单独控制设备特有参数(如空调调节温度,不改变开关状态)
/// 单独控制设备特有参数,不改变开关状态(如空调调节温度、灯光调节亮度)
/// [deviceId]:设备ID
/// [extraParams]:仅传递特有参数(如temperature:26)
Future<void> controlDeviceParams({
required String deviceId,
required Map<String, dynamic> extraParams,
}) async {
// 复用并发锁逻辑,避免与开关控制冲突
if (_deviceLock[deviceId] == true) {
_logger.d('设备正在控制中,禁止重复操作:deviceId=$deviceId');
_setErrorMessage('操作过于频繁,请稍后再试');
return;
}
_deviceLock[deviceId] = true;
_isControlling = true;
notifyListeners();
final deviceIndex = _deviceList.indexWhere((device) => device.id == deviceId);
if (deviceIndex == -1) {
_logger.e('设备不存在,无法控制:deviceId=$deviceId');
_setErrorMessage('设备不存在,无法控制');
_deviceLock[deviceId] = false;
_isControlling = false;
notifyListeners();
return;
}
// 保存原始状态(仅特有参数)
final originalDevice = _cloneDevice(_deviceList[deviceIndex]);
_logger.d('保存设备特有参数原始状态:deviceId=$deviceId');
try {
// 乐观更新:仅更新特有参数UI
_updateDeviceUIParams(deviceIndex, extraParams);
notifyListeners();
// 构建请求参数(保留原有status,仅追加特有参数)
final currentStatus = _deviceList[deviceIndex].status.name;
final params = {'status': currentStatus, ...extraParams};
final updatedDevice = await _deviceRepository.controlDevice(
deviceId: deviceId,
params: params,
);
// 请求成功:覆盖最新数据
_deviceList[deviceIndex] = updatedDevice;
_clearErrorMessage();
} catch (e) {
// 回滚特有参数到原始状态
_deviceList[deviceIndex] = originalDevice;
final errorMsg = GlobalExceptionHandler.getErrorMessage(e);
_setErrorMessage(errorMsg);
_logger.e('设备参数控制失败:$errorMsg');
} finally {
_deviceLock[deviceId] = false;
_isControlling = false;
notifyListeners();
}
}
// 辅助方法:仅更新设备特有参数UI(乐观更新)
void _updateDeviceUIParams(int deviceIndex, Map<String, dynamic> extraParams) {
if (deviceIndex < 0 || deviceIndex >= _deviceList.length) return;
final currentDevice = _deviceList[deviceIndex];
BaseDevice updatedDevice;
if (currentDevice is AirConditionerDevice) {
updatedDevice = AirConditionerDevice(
id: currentDevice.id,
name: currentDevice.name,
type: currentDevice.type,
status: currentDevice.status, // 保留原有开关状态
online: currentDevice.online,
lastActiveTime: currentDevice.lastActiveTime,
iconUrl: currentDevice.iconUrl,
temperature: extraParams['temperature'] ?? currentDevice.temperature,
mode: extraParams['mode'] ?? currentDevice.mode,
fanSpeed: extraParams['fanSpeed'] ?? currentDevice.fanSpeed,
);
} else if (currentDevice is LightDevice) {
updatedDevice = LightDevice(
id: currentDevice.id,
name: currentDevice.name,
type: currentDevice.type,
status: currentDevice.status,
online: currentDevice.online,
lastActiveTime: currentDevice.lastActiveTime,
iconUrl: currentDevice.iconUrl,
brightness: extraParams['brightness'] ?? currentDevice.brightness,
color: extraParams['color'] ?? currentDevice.color,
isWarm: extraParams['isWarm'] ?? currentDevice.isWarm,
);
} else if (currentDevice is CurtainDevice) {
updatedDevice = CurtainDevice(
id: currentDevice.id,
name: currentDevice.name,
type: currentDevice.type,
status: currentDevice.status,
online: currentDevice.online,
lastActiveTime: currentDevice.lastActiveTime,
iconUrl: currentDevice.iconUrl,
opening: extraParams['opening'] ?? currentDevice.opening,
direction: extraParams['direction'] ?? currentDevice.direction,
);
} else {
updatedDevice = currentDevice;
}
_deviceList[deviceIndex] = updatedDevice;
_logger.d('乐观更新设备特有参数UI成功:deviceId=${currentDevice.id}');
}
}
【代码核心详解】:
controlDeviceParams方法专为“不改变开关状态、仅调节参数”设计(如空调调温、灯光调亮度),解决了“开关控制”和“参数调节”的逻辑分离,避免用户调节温度时误触开关状态;- 复用并发锁逻辑,确保同一设备的“开关控制”和“参数调节”不会并发执行,从根本上避免状态错乱;
_updateDeviceUIParams仅更新特有参数、保留开关状态,符合用户操作习惯(用户调温时不会希望开关状态改变);- 请求参数构建时主动携带当前开关状态,兼容后端接口“必须传status”的要求,避免参数校验失败。
【高频问题&解决方案】:
| 问题场景 | 根因 | 解决方案 |
|---|---|---|
| 调节空调温度后,开关状态被意外关闭 | 后端接口要求必须传status,前端未携带导致默认赋值为off | 主动获取当前设备开关状态,拼接至请求参数中,确保status与当前UI一致 |
| 并发调节温度+开关,导致状态回滚后参数错乱 | 未对“参数调节”加锁,与开关控制冲突 | 复用_deviceLock并发锁,所有设备操作共用同一把锁,禁止并发 |
| 调温后UI立即更新,但后端返回参数不一致(如后端限制温度16-30,用户调至35) | 前端未做参数范围校验,乐观更新后回滚体验差 | 新增前端参数校验(如空调温度16-30℃、亮度0-100),提前拦截非法操作,代码示例: `if (temperature <16 |
步骤3:业务层逻辑测试(避免UI层开发后踩坑)
业务层封装完成后,必须先脱离UI层做纯逻辑测试,验证核心流程的正确性,这是新手最容易忽略的步骤(直接写UI,出现问题后难以定位是UI还是业务层的问题)。
测试方式:在main.dart中临时注入依赖,调用控制方法:
void main() async {
await initInjection(); // 初始化依赖注入
final deviceProvider = getIt<DeviceProvider>();
// 测试1:开关控制
await deviceProvider.toggleDeviceStatus(
deviceId: 'dev_123456',
targetStatus: DeviceStatusEnum.on,
);
// 测试2:参数调节
await deviceProvider.controlDeviceParams(
deviceId: 'dev_123456',
extraParams: {'temperature': 25},
);
runApp(const MyApp());
}
测试重点:
- 无设备时是否提示“设备不存在”;
- 并发调用两次控制方法,是否提示“操作过于频繁”;
- 网络超时后,状态是否回滚到原始值;
- 调节温度超出16-30范围时,是否提前拦截;
- 控制成功后,缓存是否同步更新。
模块3:UI层开发——控制交互组件(用户体验核心)
业务层逻辑验证无误后,进入UI层开发。UI层的核心是“让用户操作更顺手”,同时适配OpenHarmony多终端(手机/平板/DAYU200开发板)的触控体验,解决“开发板滑块触控精度低、平板按钮布局松散”等问题。
步骤1:核心交互组件设计原则(贴合用户习惯)
- 一致性:所有设备的开关样式统一,参数调节组件风格统一(如滑块均使用Material Design 3风格);
- 易用性:开发板/平板端增大触控区域(按钮/滑块尺寸≥48dp),避免触控精准度过高导致操作失败;
- 反馈性:操作时显示加载动画,失败时显示SnackBar提示,离线设备禁用组件并标注“离线”;
- 容错性:参数调节设置上下限(如温度16-30℃),超出范围时禁用确认按钮或自动修正。
步骤2:设备开关组件(核心代码+适配)
文件路径:lib/presentation/widgets/device/device_switch_widget.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:smart_home_flutter/domain/providers/device_provider.dart';
import 'package:smart_home_flutter/data/models/device_model.dart';
class DeviceSwitchWidget extends StatelessWidget {
final BaseDevice device;
final double size; // 适配多终端的尺寸参数
const DeviceSwitchWidget({
super.key,
required this.device,
this.size = 50.0, // 默认尺寸,开发板/平板传80.0
});
Widget build(BuildContext context) {
final provider = Provider.of<DeviceProvider>(context, listen: false);
final isControlling = Provider.of<DeviceProvider>(context).isControlling;
return SizedBox(
width: size,
height: size / 2, // 开关宽高比2:1
child: Switch.adaptive(
value: device.status == DeviceStatusEnum.on,
onChanged: device.online && !isControlling
? (isOn) async {
// 调用业务层控制方法
await provider.toggleDeviceStatus(
deviceId: device.id,
targetStatus: isOn ? DeviceStatusEnum.on : DeviceStatusEnum.off,
);
// 错误提示(读取Provider的errorMessage)
if (provider.errorMessage.isNotEmpty) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(provider.errorMessage),
backgroundColor: Colors.red,
duration: const Duration(seconds: 2),
),
);
}
}
}
: null, // 离线/控制中时禁用开关
activeColor: Theme.of(context).primaryColor,
inactiveThumbColor: device.online ? Colors.grey[400] : Colors.grey[200],
inactiveTrackColor: device.online ? Colors.grey[300] : Colors.grey[100],
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
);
}
}
【适配细节&问题解决】:
- DAYU200开发板适配:开发板触控屏精度低,默认开关尺寸(48dp)易误触,通过
size参数将开发板端开关尺寸放大至80dp,触控区域提升67%; - 离线状态优化:离线设备的开关置灰,且
onChanged设为null,同时在开关下方添加“设备离线”文本提示,避免用户疑惑; - 控制中状态:全局
isControlling为true时,开关禁用并显示加载动画(叠加Stack+CircularProgressIndicator),代码示例:// 在Switch外层包裹Stack Stack( alignment: Alignment.center, children: [ Switch.adaptive(...), if (isControlling) const CircularProgressIndicator( strokeWidth: 2, size: 16, valueColor: AlwaysStoppedAnimation(Colors.white), ), ], )
步骤3:空调参数调节组件(滑块+弹窗,核心代码)
文件路径:lib/presentation/widgets/device/air_conditioner_control_widget.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:smart_home_flutter/domain/providers/device_provider.dart';
import 'package:smart_home_flutter/data/models/device_model.dart';
class AirConditionerControlWidget extends StatelessWidget {
final AirConditionerDevice device;
final bool isTablet; // 是否平板/开发板
const AirConditionerControlWidget({
super.key,
required this.device,
this.isTablet = false,
});
Widget build(BuildContext context) {
final provider = Provider.of<DeviceProvider>(context, listen: false);
final double sliderWidth = isTablet ? 300 : 200; // 多终端尺寸适配
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 温度调节滑块
Row(
children: [
const Text('温度:'),
Text('${device.temperature}℃'),
const SizedBox(width: 16),
SizedBox(
width: sliderWidth,
child: Slider.adaptive(
value: device.temperature.toDouble(),
min: 16,
max: 30,
divisions: 14, // 16-30共14个档位
onChanged: device.online
? (value) async {
// 实时更新UI(防抖处理,避免频繁请求)
await Future.delayed(const Duration(milliseconds: 300));
await provider.controlDeviceParams(
deviceId: device.id,
extraParams: {'temperature': value.toInt()},
);
}
: null,
activeColor: Colors.blue,
inactiveColor: Colors.grey[300],
thumbColor: device.online ? Colors.blue : Colors.grey,
// 开发板适配:增大滑块拇指尺寸
thumbShape: RoundSliderThumbShape(
enabledThumbRadius: isTablet ? 12 : 8,
),
),
),
],
),
// 模式选择(空调特有:制冷/制热/送风)
Row(
children: [
const Text('模式:'),
const SizedBox(width: 8),
...['cool', 'heat', 'fan', 'auto']
.map((mode) => Padding(
padding: const EdgeInsets.only(right: 8),
child: ElevatedButton(
onPressed: device.online
? () async {
await provider.controlDeviceParams(
deviceId: device.id,
extraParams: {'mode': mode},
);
}
: null,
style: ElevatedButton.styleFrom(
backgroundColor: device.mode == mode
? Colors.blue
: Colors.grey[200],
// 平板/开发板增大按钮尺寸
minimumSize: isTablet
? const Size(80, 40)
: const Size(60, 36),
),
child: Text(
mode == 'cool' ? '制冷' :
mode == 'heat' ? '制热' :
mode == 'fan' ? '送风' : '自动',
style: TextStyle(
color: device.mode == mode ? Colors.white : Colors.black,
fontSize: isTablet ? 16 : 14,
),
),
),
))
.toList(),
],
),
],
);
}
}
【核心问题&解决方案】:
- 滑块频繁触发请求:用户滑动滑块时,每拖动一个像素就触发一次请求,导致后端压力大、前端卡顿→ 添加300ms防抖延迟,用户停止滑动后再发起请求;
- 开发板滑块拖动不精准:默认滑块拇指尺寸过小(8dp),开发板触控屏易滑错→ 开发板/平板端将拇指半径放大至12dp,提升触控容错率;
- 模式选择按钮布局混乱:平板端按钮挤在一起→ 根据
isTablet参数动态调整按钮尺寸和间距,平板端按钮宽度从60dp增至80dp。
步骤4:DeviceCard集成控制组件(多设备类型适配)
文件路径:lib/presentation/widgets/device/device_card.dart(核心修改部分)
Widget build(BuildContext context) {
// 判断设备类型(手机/平板/开发板)
final isTablet = MediaQuery.of(context).size.width >= 600;
final isDayu200 = MediaQuery.of(context).size.width >= 1200;
final double cardWidth = isDayu200 ? 500 : (isTablet ? 400 : double.infinity);
return Card(
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
child: SizedBox(
width: cardWidth,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 设备名称+开关(顶部)
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
device.name,
style: TextStyle(
fontSize: isTablet ? 18 : 16,
fontWeight: FontWeight.bold,
),
),
DeviceSwitchWidget(
device: device,
size: isTablet ? 80 : 50,
),
],
),
// 离线提示(关键:用户易忽略离线状态)
if (!device.online)
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
'⚠️ 设备已离线,无法控制',
style: TextStyle(
color: Colors.red,
fontSize: isTablet ? 14 : 12,
),
),
),
// 设备类型特有控制组件
if (device is AirConditionerDevice)
AirConditionerControlWidget(
device: device as AirConditionerDevice,
isTablet: isTablet || isDayu200,
),
if (device is LightDevice)
LightControlWidget( // 灯光组件逻辑同空调,此处省略核心代码
device: device as LightDevice,
isTablet: isTablet || isDayu200,
),
if (device is CurtainDevice)
CurtainControlWidget( // 窗帘组件逻辑同空调,此处省略核心代码
device: device as CurtainDevice,
isTablet: isTablet || isDayu200,
),
],
),
),
),
);
}
【用户体验优化细节】:
- 离线提示使用⚠️图标+红色文字,比单纯置灰更醒目,解决“用户不知道设备离线,以为APP故障”的问题;
- 多终端卡片宽度适配:DAYU200开发板卡片宽度固定500dp,避免大屏上卡片过宽导致交互组件分散;
- 控制组件与开关的间距统一(16dp),符合Material Design间距规范,提升视觉一致性。
模块4:OpenHarmony专属适配(跨终端核心)
Flutter代码写完后,必须针对OpenHarmony做专属适配,否则会出现“手机端正常、开发板/平板端异常”的问题,这也是鸿蒙跨终端开发的核心难点。
步骤1:网络权限配置(解决请求被拦截)
文件路径:ohos/entry/src/main/module.json5
{
"module": {
"name": "entry",
"type": "entry",
"deviceTypes": ["phone", "tablet", "tv", "wearable", "liteWearable", "smartVision"],
"abilities": [...],
"requestPermissions": [
{
"name": "ohos.permission.INTERNET",
"reason": "$string:internet_reason",
"usedScene": {
"abilities": ["entry.MainAbility"],
"when": "always"
}
},
{
"name": "ohos.permission.GET_NETWORK_INFO",
"reason": "$string:network_info_reason",
"usedScene": {
"abilities": ["entry.MainAbility"],
"when": "always"
}
}
]
}
}
【问题&解决方案】:
- 鸿蒙后台网络请求被拦截:默认情况下,鸿蒙应用退到后台后会限制网络请求,导致设备控制失败→ 在
usedScene中设置when: "always",允许后台网络请求; - 权限申请提示不友好:未配置
reason导致用户拒绝权限→ 在string.json中添加权限申请说明(如“需要网络权限以控制智能设备”),提升用户授权率。
步骤2:开发板触控适配(解决操作失灵)
- 触控区域放大:所有按钮、滑块、开关的触控区域≥48dp(开发板触控屏的最小有效触控尺寸);
- 禁用双击缩放:开发板端禁用Flutter页面的双击缩放,避免误触导致页面放大,代码:
// 在MaterialApp中配置 builder: (context, child) { return MediaQuery( data: MediaQuery.of(context).copyWith( doubleTapZoomScale: 1.0, // 禁用双击缩放 textScaleFactor: 1.2, // 开发板端文字放大20%,提升可读性 ), child: child!, ); }, - 滑动阻尼调整:开发板端增大列表滑动阻尼,避免滑动过快导致加载更多误触发,代码:
physics: const ClampingScrollPhysics( parent: BouncingScrollPhysics( decelerationRate: ScrollDecelerationRate.fast, // 快速减速 ), ),
步骤3:鸿蒙原生桥接(解决Flutter能力不足)
部分鸿蒙设备控制需要调用原生API(如蓝牙设备),通过MethodChannel实现Flutter与鸿蒙原生通信:
Flutter端代码:
// lib/core/channel/ohos_channel.dart
import 'package:flutter/services.dart';
class OhosChannel {
static const MethodChannel _channel = MethodChannel('smart_home/ohos');
// 调用鸿蒙原生蓝牙控制方法
static Future<bool> controlBluetoothDevice(String deviceId, Map<String, dynamic> params) async {
try {
final result = await _channel.invokeMethod(
'controlBluetoothDevice',
{'deviceId': deviceId, 'params': params},
);
return result as bool;
} catch (e) {
_logger.e('调用鸿蒙原生蓝牙控制失败:$e');
return false;
}
}
}
鸿蒙原生端代码(ArkTS):
// ohos/entry/src/main/ets/ability/MainAbility.ts
import hilog from '@ohos.hilog';
import window from '@ohos.window';
import AbilityConstant from '@ohos.app.ability.AbilityConstant';
import UIAbility from '@ohos.app.ability.UIAbility';
import { BusinessError } from '@ohos.base';
import { createFromProvider } from '@ohos.promptAction';
export default class MainAbility extends UIAbility {
onCreate(want, launchParam) {
hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onCreate');
// 注册MethodChannel
const channel = new NativeMethodChannel('smart_home/ohos');
channel.registerMethod('controlBluetoothDevice', (data) => {
// 原生蓝牙控制逻辑(此处省略,调用鸿蒙蓝牙API)
const deviceId = data.deviceId;
const params = data.params;
return true;
});
}
}
【核心问题&解决方案】:
- Channel通信失败:Flutter与鸿蒙原生的Channel名称不一致→ 统一使用
smart_home/ohos,且原生端在Ability.onCreate中注册; - 蓝牙权限申请失败:鸿蒙原生端未动态申请蓝牙权限→ 在原生代码中添加权限申请逻辑,代码示例:
import abilityAccessCtrl from '@ohos.abilityAccessCtrl'; const atManager = abilityAccessCtrl.createAtManager(); atManager.requestPermissionsFromUser(this.context, ['ohos.permission.BLUETOOTH']);
模块5:多终端测试验证(确保全场景可用)
Day5开发完成后,必须在3类终端上完成全场景测试,避免上线后出现“部分设备不可用”的问题:
| 测试终端 | 测试重点 | 常见问题 | 解决方案 |
|---|---|---|---|
| OpenHarmony手机(Mate 60 Pro) | 基础功能、网络稳定性、电池消耗 | 控制频繁导致电量快速下降 | 优化MQTT长连接(Day10),减少轮询;控制请求添加防抖,降低请求频率 |
| 平板(HarmonyOS 4.0+) | 布局适配、触控体验 | 按钮布局松散、滑块拖动不流畅 | 动态调整组件尺寸和间距;优化滑块物理特性 |
| DAYU200开发板 | 触控精度、操作响应、离线控制 | 开关误触、滑块拖动失灵 | 放大触控区域;禁用双击缩放;调整滑动阻尼 |
测试用例(核心):
- 在线设备:开关控制→ 调节参数→ 关闭开关,验证状态是否一致;
- 离线设备:点击开关→ 调节参数,验证是否禁用并提示“离线”;
- 网络超时:断网后操作设备,验证状态是否回滚、错误提示是否准确;
- 并发操作:快速点击开关+调节参数,验证是否提示“操作频繁”;
- 参数超限:调节温度至31℃,验证是否拦截并提示“温度范围16-30℃”。
Day5开发总结(个性化+落地性)
Day5的开发是整个智能家居项目从“基础展示”到“核心可用”的关键一步,我们不仅实现了设备远程控制的核心功能,更通过“乐观更新”解决了网络延迟导致的用户体验问题,通过“并发锁”解决了状态错乱问题,通过“多终端适配”解决了OpenHarmony跨设备兼容问题。
从代码层面,我们严格遵循分层架构(数据层→业务层→UI层),所有控制逻辑封装在业务层,UI层仅负责交互和展示,降低了代码耦合度;从体验层面,我们围绕“用户习惯”和“鸿蒙设备特性”做了大量细节优化,比如开发板触控区域放大、离线状态醒目提示、参数调节防抖等,这些细节看似微小,却是决定APP“好用”还是“能用”的关键。
接下来Day6我们将聚焦“设备搜索、筛选、分组”,解决“设备过多时查找困难”的问题,同时复用Day5的控制逻辑,实现“筛选后快速控制”的功能。
个性化结尾
如果你在Day5的开发中遇到了“状态回滚失败”“鸿蒙权限申请被拒”“开发板触控失灵”等问题,欢迎在评论区留言讨论——这些问题都是鸿蒙跨终端开发中最常见的“坑”,踩过这些坑,你对Flutter+OpenHarmony的理解会更深入。
另外,提醒大家:代码写完不是结束,测试才是!尤其是智能家居APP,涉及到实际设备控制,一个小小的状态错误可能导致设备误操作(如深夜自动打开空调),因此多终端、多场景的测试一定要做足。
下一篇Day6,我们继续攻克“设备管理效率”的问题,让你的智能家居APP不仅能“控制设备”,还能“高效管理设备”!
更多推荐




所有评论(0)