【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不同,需要先检查权限状态,再根据状态处理。

解决步骤

  1. module.json5中正确配置权限声明
  2. 使用Geolocator.checkPermission()检查当前权限状态
  3. 如果权限被永久拒绝,引导用户到系统设置开启
if (permission == LocationPermission.deniedForever) {
  // 引导用户到设置页面
  await Geolocator.openAppSettings();
}

坑二:网络请求返回中文乱码

报错现象:API返回的中文建议变成了乱码???

问题原因:鸿蒙平台默认编码设置与服务器不一致。

解决步骤

  1. 在Dio配置中强制设置UTF-8编码
  2. 检查服务器响应头是否包含正确的Content-Type
Dio dio = Dio(BaseOptions(
  responseType: ResponseType.plain,
));

// 手动解析JSON
final jsonString = utf8.decode(response.data);
final data = jsonDecode(jsonString);

坑三:UI布局在鸿蒙设备上显示异常

报错现象:圆形紫外线指数卡片在部分鸿蒙设备上变成了椭圆

问题原因:鸿蒙设备的屏幕密度计算方式与Android略有不同

解决步骤

  1. 使用MediaQuery获取屏幕密度
  2. 使用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. 首页展示:应用启动后,首先显示加载动画,然后展示当前位置的紫外线指数
  2. 紫外线卡片:圆形卡片显示当前紫外线数值,颜色根据强度变化(绿色→黄色→橙色→红色→紫色)
  3. 防护建议:根据紫外线强度提供对应的防护建议
  4. 知识科普:底部显示紫外线相关知识,帮助用户了解紫外线
  5. 下拉刷新:支持下拉刷新获取最新数据

截图说明

  • 截图1:紫外线指数为7.2(高水平),显示橙色圆形卡片
  • 截图2:紫外线指数为2.1(低水平),显示绿色圆形卡片
  • 截图3:下拉刷新状态,显示加载动画
  • 截图4:权限请求弹窗

八、大二学生真实学习总结

这次开发防紫外线测试APP让我收获颇丰,作为一个大二学生,我有以下几点深刻体会:

1. 跨平台开发的理解更深了

以前总觉得Flutter"一次开发,多端运行"就是一句口号,这次在鸿蒙平台上开发才真正体会到这句话的含义。虽然有一些适配问题,但大部分代码确实可以直接复用,大大提高了开发效率。

2. 遇到问题不要慌,冷静分析最重要

开发过程中遇到了很多报错,一开始我很慌,不知道该怎么办。后来慢慢学会了看报错信息,分析问题原因,然后一步步解决。现在遇到问题反而觉得是学习的机会。

3. 实践是最好的学习方式

课本上学了很多理论知识,但真正动手开发才发现很多细节是书本上学不到的。比如权限配置、网络请求异常处理、UI适配等,这些都需要在实践中不断摸索。

4. 学会了如何做用户体验优化

以前写代码只关注功能实现,这次开发让我意识到用户体验的重要性。比如加载状态的显示、错误提示的友好性、下拉刷新功能等,这些细节能让用户感觉更舒服。

5. 对鸿蒙生态更有信心了

以前觉得鸿蒙生态还不完善,但这次开发体验让我看到了鸿蒙的进步。虽然还有一些兼容性问题,但整体来说已经比较成熟了,相信未来会越来越好。

总结

通过这次防紫外线测试APP的开发,我不仅学会了Flutter在鸿蒙平台上的网络请求、权限管理等技术,更重要的是培养了解决问题的能力和耐心。作为一个大二学生,我还有很多东西要学,但我相信只要保持这份热情,不断实践,一定能成为一名优秀的开发者!

如果你也对Flutter鸿蒙开发感兴趣,欢迎加入开源鸿蒙跨平台社区,一起学习进步!

作者:ShineQiu
上海本科大二计算机科学与技术专业学生
热爱Flutter鸿蒙开发,乐于分享学习心得

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐