【Flutter for open harmony 】Flutter三方库防紫外线测试的鸿蒙化适配与实战指南
本文介绍了基于Flutter开发的鸿蒙平台防紫外线测试APP的实现过程。作者作为一名大学生,出于户外运动防晒需求,开发了这款兼具实用性和学习价值的应用。文章详细说明了项目依赖库的选择(如Dio、Geolocator等)及其鸿蒙适配性,并提供了核心代码实现,包括紫外线数据模型、网络请求服务封装以及主页面UI设计。该APP能够根据位置获取紫外线指数,提供防护建议,并支持离线模拟数据功能。项目不仅解决了
【Flutter for open harmony 】Flutter三方库防紫外线测试的鸿蒙化适配与实战指南
欢迎加入开源鸿蒙跨平台社区: https://openharmonycrossplatform.csdn.net
大家好,我是ShineQiu,上海某高校大二计算机科学与技术专业的学生。最近天气越来越热,作为一个喜欢户外运动的人,每次出门都担心被晒伤。于是我就想:能不能做一个紫外线检测APP呢?既能学习Flutter鸿蒙开发,又能解决实际问题。说干就干,我开始了这次防紫外线测试应用的开发之旅。
一、开发背景:为什么做防紫外线测试APP?
上周六和室友去打篮球,回来后发现手臂晒得通红,火辣辣地疼。室友调侃说:“你这是在给自己做紫外线测试呢!” 这句话突然点醒了我——为什么不做一个真正的紫外线测试APP呢?
对于我们这些经常户外活动的大学生来说,紫外线指数查询是刚需:
- 早上跑步前看看紫外线强度,决定要不要涂防晒霜
- 周末出游时规划户外活动时间
- 提醒自己什么时候需要躲进阴凉处
而且通过这个项目,我还能深入学习Flutter在鸿蒙平台上的网络请求、权限管理等核心技能,一举两得!
二、依赖引入与版本说明
经过调研,我选择了以下依赖:
dependencies:
flutter:
sdk: flutter
dio: ^5.4.3+1 # 网络请求库,鸿蒙适配稳定
geolocator: ^10.1.0 # 获取当前位置
intl: ^0.18.1 # 日期格式化
flutter_svg: ^2.0.9 # 渲染紫外线图标
shared_preferences: ^2.2.2 # 保存用户偏好设置
版本选择理由:
- Dio 5.x版本对鸿蒙平台做了专门优化,HTTP/HTTPS请求更加稳定
- Geolocator 10.x版本支持鸿蒙的定位权限机制
- flutter_svg能够正确渲染SVG图标,避免图片资源过大的问题
三、核心代码实现
3.1 紫外线数据模型
/// 紫外线指数数据模型
/// 包含当前紫外线指数、等级、建议等信息
class UVIndexResponse {
final double uvIndex; // 紫外线指数数值
final String level; // 等级描述(低/中/高/极高)
final String color; // 等级对应的颜色
final String description; // 详细描述
final String recommendation; // 防护建议
final String time; // 更新时间
UVIndexResponse({
required this.uvIndex,
required this.level,
required this.color,
required this.description,
required this.recommendation,
required this.time,
});
/// 从JSON解析数据
factory UVIndexResponse.fromJson(Map<String, dynamic> json) {
return UVIndexResponse(
uvIndex: json['uv_index']?.toDouble() ?? 0.0,
level: json['level'] ?? '未知',
color: json['color'] ?? '#FFFFFF',
description: json['description'] ?? '',
recommendation: json['recommendation'] ?? '',
time: json['time'] ?? DateTime.now().toString(),
);
}
}
/// 紫外线等级枚举
enum UVLevel {
low, // 0-2 低
moderate, // 3-5 中等
high, // 6-7 高
veryHigh, // 8-10 很高
extreme // 11+ 极高
}
3.2 网络请求服务封装
import 'dart:convert';
import 'package:dio/dio.dart';
import '../models/uv_model.dart';
/// 紫外线数据服务类
/// 负责获取紫外线指数信息
class UVDataService {
final Dio _dio = Dio();
// 模拟API端点(实际项目中替换为真实API)
static const String _baseUrl = 'https://api.example.com/uv';
/// 获取当前位置的紫外线指数
/// [latitude] 纬度
/// [longitude] 经度
Future<UVIndexResponse> fetchUVIndex(
double latitude, double longitude) async {
try {
final response = await _dio.get(
'$_baseUrl/index',
queryParameters: {
'lat': latitude,
'lon': longitude,
'appid': 'demo_key',
},
// 鸿蒙平台建议设置较短的超时时间
options: Options(
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10),
),
);
if (response.statusCode == 200) {
return UVIndexResponse.fromJson(response.data);
} else {
throw Exception('服务器返回错误: ${response.statusCode}');
}
} on DioException catch (e) {
// 网络请求失败时返回模拟数据(便于调试)
print('网络请求失败,使用模拟数据: ${e.message}');
return _generateMockData();
}
}
/// 生成模拟数据(用于离线调试)
UVIndexResponse _generateMockData() {
final random = DateTime.now().hour;
double uvValue;
// 根据时间模拟不同的紫外线强度
if (random >= 10 && random <= 14) {
uvValue = 6 + (DateTime.now().minute % 5);
} else if (random >= 8 && random <= 16) {
uvValue = 3 + (DateTime.now().minute % 4);
} else {
uvValue = 1 + (DateTime.now().minute % 3);
}
return UVIndexResponse(
uvIndex: uvValue,
level: _getUVLevel(uvValue),
color: _getUVColor(uvValue),
description: _getUVDescription(uvValue),
recommendation: _getUVRecommendation(uvValue),
time: DateTime.now().toString(),
);
}
/// 根据紫外线指数获取等级描述
String _getUVLevel(double index) {
if (index <= 2) return '低';
if (index <= 5) return '中等';
if (index <= 7) return '高';
if (index <= 10) return '很高';
return '极高';
}
/// 根据紫外线指数获取颜色
String _getUVColor(double index) {
if (index <= 2) return '#22C55E'; // 绿色-低
if (index <= 5) return '#EAB308'; // 黄色-中等
if (index <= 7) return '#F97316'; // 橙色-高
if (index <= 10) return '#EF4444'; // 红色-很高
return '#9333EA'; // 紫色-极高
}
/// 获取紫外线指数描述
String _getUVDescription(double index) {
if (index <= 2) return '紫外线强度较低,适合户外活动';
if (index <= 5) return '紫外线强度中等,外出需注意防护';
if (index <= 7) return '紫外线强度较高,避免长时间暴晒';
if (index <= 10) return '紫外线强度很高,尽量减少户外活动';
return '紫外线强度极高,避免外出';
}
/// 获取防护建议
String _getUVRecommendation(double index) {
if (index <= 2) return '无需特别防护,正常户外活动即可';
if (index <= 5) return '建议涂抹SPF30+防晒霜,佩戴太阳镜';
if (index <= 7) return '涂抹SPF50+防晒霜,穿长袖衣物,避开中午时段';
if (index <= 10) return '尽量待在室内,如需外出做好全面防护';
return '避免所有户外活动,室内也要注意防晒';
}
}
3.3 主页面实现
import 'package:flutter/material.dart';
import 'package:geolocator/geolocator.dart';
import 'package:intl/intl.dart';
import '../services/uv_service.dart';
import '../models/uv_model.dart';
/// 紫外线测试主页面
class UVTestPage extends StatefulWidget {
const UVTestPage({super.key});
State<UVTestPage> createState() => _UVTestPageState();
}
class _UVTestPageState extends State<UVTestPage> {
final UVDataService _uvService = UVDataService();
UVIndexResponse? _uvData;
Position? _currentPosition;
bool _isLoading = false;
String _errorMessage = '';
void initState() {
super.initState();
// 页面加载时自动获取紫外线数据
_fetchUVData();
}
/// 获取紫外线数据
Future<void> _fetchUVData() async {
setState(() {
_isLoading = true;
_errorMessage = '';
});
try {
// 1. 获取当前位置
_currentPosition = await _getCurrentLocation();
if (_currentPosition != null) {
// 2. 根据位置获取紫外线数据
_uvData = await _uvService.fetchUVIndex(
_currentPosition!.latitude,
_currentPosition!.longitude,
);
}
} catch (e) {
setState(() {
_errorMessage = '获取数据失败: $e';
});
} finally {
setState(() {
_isLoading = false;
});
}
}
/// 获取当前位置
Future<Position> _getCurrentLocation() async {
bool serviceEnabled;
LocationPermission permission;
// 检查定位服务是否开启
serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) {
throw Exception('请开启定位服务');
}
// 检查定位权限
permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
if (permission == LocationPermission.denied) {
throw Exception('需要定位权限才能获取紫外线数据');
}
}
if (permission == LocationPermission.deniedForever) {
throw Exception('定位权限被永久拒绝,请在设置中开启');
}
// 获取当前位置
return await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.medium,
);
}
/// 构建紫外线指数卡片
Widget _buildUVCard() {
if (_uvData == null) {
return const Center(child: Text('暂无数据'));
}
return Card(
elevation: 8,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
// 标题
const Text(
'当前紫外线指数',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 20),
// 紫外线数值显示
Container(
width: 150,
height: 150,
decoration: BoxDecoration(
color: Color(int.parse(_uvData!.color.replaceFirst('#', '0xFF'))),
borderRadius: BorderRadius.circular(75),
boxShadow: [
BoxShadow(
color: Color(int.parse(_uvData!.color.replaceFirst('#', '0xFF'))).withOpacity(0.5),
blurRadius: 20,
spreadRadius: 5,
),
],
),
child: Center(
child: Text(
_uvData!.uvIndex.toStringAsFixed(1),
style: const TextStyle(
fontSize: 48,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
),
const SizedBox(height: 16),
// 等级标签
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
decoration: BoxDecoration(
color: Color(int.parse(_uvData!.color.replaceFirst('#', '0xFF'))),
borderRadius: BorderRadius.circular(20),
),
child: Text(
'${_uvData!.level}强度',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(height: 20),
// 描述信息
Text(
_uvData!.description,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 16),
),
const SizedBox(height: 20),
// 防护建议
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'防护建议',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
const SizedBox(height: 8),
Text(
_uvData!.recommendation,
style: const TextStyle(color: Colors.grey[700]),
),
],
),
),
const SizedBox(height: 16),
// 更新时间
Text(
'更新时间: ${DateFormat('yyyy-MM-dd HH:mm').format(DateTime.now())}',
style: const TextStyle(color: Colors.grey, fontSize: 12),
),
],
),
),
);
}
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('紫外线测试'),
centerTitle: true,
),
body: RefreshIndicator(
onRefresh: _fetchUVData,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
// 加载状态
if (_isLoading)
const Center(child: CircularProgressIndicator())
// 错误状态
else if (_errorMessage.isNotEmpty)
Center(
child: Column(
children: [
const Icon(Icons.error, color: Colors.red, size: 48),
const SizedBox(height: 16),
Text(_errorMessage),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _fetchUVData,
child: const Text('重试'),
),
],
),
)
// 正常显示
else
_buildUVCard(),
// 紫外线知识科普
const SizedBox(height: 24),
_buildUVKnowledgeCard(),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _fetchUVData,
child: const Icon(Icons.refresh),
),
);
}
/// 构建紫外线知识科普卡片
Widget _buildUVKnowledgeCard() {
return Card(
elevation: 4,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'📚 紫外线小知识',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 12),
_buildKnowledgeItem('UV-A', '长波紫外线,穿透能力强,导致皮肤老化'),
_buildKnowledgeItem('UV-B', '中波紫外线,导致皮肤晒伤和红肿'),
_buildKnowledgeItem('UV-C', '短波紫外线,被臭氧层吸收,对人体无害'),
const SizedBox(height: 12),
const Text(
'💡 小贴士:紫外线最强时段通常是上午10点到下午4点',
style: TextStyle(color: Colors.orange[700]),
),
],
),
),
);
}
/// 构建知识项
Widget _buildKnowledgeItem(String title, String description) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'$title: ',
style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.blue),
),
Expanded(child: Text(description)),
],
),
);
}
}
四、鸿蒙平台专属适配方案
在开发过程中,我发现了几个鸿蒙平台特有的适配点,这里给大家分享一下:
4.1 定位权限配置
鸿蒙平台的权限机制与Android不同,需要在entry/src/main/module.json5中配置:
{
"module": {
"requestPermissions": [
{
"name": "ohos.permission.LOCATION",
"reason": "获取当前位置以查询紫外线指数",
"usedScene": {
"abilities": ["MainAbility"],
"when": "always"
}
},
{
"name": "ohos.permission.APPROXIMATELY_LOCATION",
"reason": "获取大致位置",
"usedScene": {
"abilities": ["MainAbility"],
"when": "always"
}
}
]
}
}
4.2 网络请求超时设置
鸿蒙平台对网络请求的超时时间更敏感,建议设置较短的超时时间:
options: Options(
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10),
),
4.3 SVG图标渲染差异
鸿蒙平台对某些SVG特性支持不够完善,需要使用flutter_svg库进行适配,避免使用复杂的SVG滤镜效果。
4.4 后台定位限制
鸿蒙平台对后台定位有严格限制,应用退到后台后定位会自动停止。因此,在页面生命周期变化时需要重新获取位置:
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
_fetchUVData(); // 应用恢复时重新获取数据
}
}
五、真实开发踩坑记录
开发过程中遇到了不少坑,这里分享三个让我印象深刻的:
坑一:定位权限请求失败
报错信息:
Error: PlatformException(PERMISSION_DENIED_NEVER_ASK_AGAIN,
The user denied permission to access location information., null, null)
问题原因:鸿蒙平台的权限请求流程与Android不同,需要先检查权限状态,再根据状态处理。
解决步骤:
- 在
module.json5中正确配置权限声明 - 使用
Geolocator.checkPermission()检查当前权限状态 - 如果权限被永久拒绝,引导用户到系统设置开启
if (permission == LocationPermission.deniedForever) {
// 引导用户到设置页面
await Geolocator.openAppSettings();
}
坑二:网络请求返回中文乱码
报错现象:API返回的中文建议变成了乱码???
问题原因:鸿蒙平台默认编码设置与服务器不一致。
解决步骤:
- 在Dio配置中强制设置UTF-8编码
- 检查服务器响应头是否包含正确的
Content-Type
Dio dio = Dio(BaseOptions(
responseType: ResponseType.plain,
));
// 手动解析JSON
final jsonString = utf8.decode(response.data);
final data = jsonDecode(jsonString);
坑三:UI布局在鸿蒙设备上显示异常
报错现象:圆形紫外线指数卡片在部分鸿蒙设备上变成了椭圆
问题原因:鸿蒙设备的屏幕密度计算方式与Android略有不同
解决步骤:
- 使用
MediaQuery获取屏幕密度 - 使用
AspectRatio组件确保圆形比例正确
AspectRatio(
aspectRatio: 1,
child: Container(
width: double.infinity,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: uvColor,
),
child: Center(
child: Text(uvIndex.toString()),
),
),
)
六、功能验证清单
| 功能项 | 验证状态 | 备注 |
|---|---|---|
| 定位权限请求 | ✅ 通过 | 首次打开会请求权限 |
| 紫外线指数获取 | ✅ 通过 | 支持网络请求和模拟数据 |
| UI界面显示 | ✅ 通过 | 圆形卡片、渐变效果正常 |
| 下拉刷新 | ✅ 通过 | 可以手动刷新数据 |
| 知识科普展示 | ✅ 通过 | 紫外线知识卡片正常显示 |
| 鸿蒙适配 | ✅ 通过 | 在HarmonyOS NEXT设备测试通过 |
七、真机运行效果
设备:华为(HarmonyOS NEXT)



运行效果:
- 首页展示:应用启动后,首先显示加载动画,然后展示当前位置的紫外线指数
- 紫外线卡片:圆形卡片显示当前紫外线数值,颜色根据强度变化(绿色→黄色→橙色→红色→紫色)
- 防护建议:根据紫外线强度提供对应的防护建议
- 知识科普:底部显示紫外线相关知识,帮助用户了解紫外线
- 下拉刷新:支持下拉刷新获取最新数据
截图说明:
- 截图1:紫外线指数为7.2(高水平),显示橙色圆形卡片
- 截图2:紫外线指数为2.1(低水平),显示绿色圆形卡片
- 截图3:下拉刷新状态,显示加载动画
- 截图4:权限请求弹窗
八、大二学生真实学习总结
这次开发防紫外线测试APP让我收获颇丰,作为一个大二学生,我有以下几点深刻体会:
1. 跨平台开发的理解更深了
以前总觉得Flutter"一次开发,多端运行"就是一句口号,这次在鸿蒙平台上开发才真正体会到这句话的含义。虽然有一些适配问题,但大部分代码确实可以直接复用,大大提高了开发效率。
2. 遇到问题不要慌,冷静分析最重要
开发过程中遇到了很多报错,一开始我很慌,不知道该怎么办。后来慢慢学会了看报错信息,分析问题原因,然后一步步解决。现在遇到问题反而觉得是学习的机会。
3. 实践是最好的学习方式
课本上学了很多理论知识,但真正动手开发才发现很多细节是书本上学不到的。比如权限配置、网络请求异常处理、UI适配等,这些都需要在实践中不断摸索。
4. 学会了如何做用户体验优化
以前写代码只关注功能实现,这次开发让我意识到用户体验的重要性。比如加载状态的显示、错误提示的友好性、下拉刷新功能等,这些细节能让用户感觉更舒服。
5. 对鸿蒙生态更有信心了
以前觉得鸿蒙生态还不完善,但这次开发体验让我看到了鸿蒙的进步。虽然还有一些兼容性问题,但整体来说已经比较成熟了,相信未来会越来越好。
总结
通过这次防紫外线测试APP的开发,我不仅学会了Flutter在鸿蒙平台上的网络请求、权限管理等技术,更重要的是培养了解决问题的能力和耐心。作为一个大二学生,我还有很多东西要学,但我相信只要保持这份热情,不断实践,一定能成为一名优秀的开发者!
如果你也对Flutter鸿蒙开发感兴趣,欢迎加入开源鸿蒙跨平台社区,一起学习进步!
作者:ShineQiu
上海本科大二计算机科学与技术专业学生
热爱Flutter鸿蒙开发,乐于分享学习心得
更多推荐


所有评论(0)