欢迎加入开源鸿蒙跨平台社区: https://openharmonycrossplatform.csdn.net

Flutter Riverpod鸿蒙实践

摘要:本文基于 LabManagementSystem 仓库的现有实验室预约业务,选择 pub.dev 上的 flutter_riverpod 作为状态管理三方库,在 Flutter for OpenHarmony 场景中实现实验室列表加载、刷新、详情切换与错误回显。文章重点放在状态管理方案如何贴合真实接口,而不是泛泛介绍框架。结论是:对于已有 Web 前端和 FastAPI 后端的项目,Riverpod 能够把 Flutter-OH 客户端中的异步请求状态、列表缓存和页面联动拆分得更清晰,适合作为鸿蒙端业务页面的基础状态层。

作者信息

  • 作者:coco.D
  • 背景:文章基于仓库现有 FastAPI + MySQL + Vue 3 结构撰写,接口字段与业务对象均来自当前项目源码
  • 适用对象:准备将现有管理系统或预约系统扩展到 OpenHarmony / HarmonyOS 客户端的开发者

选题背景

前一类鸿蒙化文章通常会聚焦网络库,比如 dio。但在真实项目里,请求只是第一步,真正影响代码可维护性的,往往是列表页面如何管理“加载中、刷新中、加载失败、详情切换、重试”等状态。

LabManagementSystem 仓库正好适合写这类内容:

  • 后端接口少而清晰,便于聚焦状态管理本身
  • 实验室列表和详情接口已经存在,不需要虚构业务
  • Web 端已有预约页,可以为 Flutter-OH 页面拆分提供参考

因此,这篇文章换一个角度,选择 pub.dev 上的 flutter_riverpod 来做 Flutter for OpenHarmony 端的状态组织。

仓库里的真实接口长什么样

这一步很重要。状态管理如果脱离真实接口,只会写成演示代码。

已有接口

根据仓库前端 frontend/src/api/index.js 与后端 backend/app/api/routes/labs.py,当前可稳定使用的接口有:

接口 返回结构 用途
GET /api/health { "status": "ok", "message": "..." } 连通性检查
GET /api/labs { "data": [...] } 实验室列表
GET /api/labs/{id} 单个对象 实验室详情

列表对象字段

后端对实验室对象的序列化方式如下:

def _lab_to_dict(lab: Lab):
    return {
        "id": lab.id,
        "name": lab.name,
        "location": lab.location,
        "capacity": lab.capacity,
        "status": lab.status,
    }

这意味着 Flutter-OH 端的状态层完全可以围绕以下模型展开:

字段 说明
id 实验室主键
name 实验室名称
location 房间或楼层位置
capacity 可容纳人数
status 当前状态

为什么在鸿蒙端选 Riverpod

针对这个仓库的实验室列表场景,如果只用 setState,短期内也能完成页面。但当需求增加到下面几类时,问题会很明显:

  • 首次加载与下拉刷新要区分
  • 列表页和详情页都依赖同一份数据
  • 页面返回时希望复用已有缓存
  • 失败后要支持重试,而不是让组件自己堆业务逻辑

Riverpod 在这些点上的优势比较直接:

  • Provider 生命周期清晰
  • 异步状态建模自然,适合 REST 接口页面
  • 与 Widget 解耦,便于做单元测试
  • 迁移到 Flutter for OpenHarmony 时不依赖平台专属 API,适合纯 Dart 层复用

Flutter for OpenHarmony 接入步骤

1. 添加依赖

dependencies:
  flutter:
    sdk: flutter
  flutter_riverpod: ^2.6.1
  dio: ^5.9.0

这里保留 dio 作为网络访问层,Riverpod 只负责状态组织。这样分层更适合后续扩展预约提交、设备申请、用户登录等模块。

2. 定义模型与仓储

class Lab {
  const Lab({
    required this.id,
    required this.name,
    required this.location,
    required this.capacity,
    required this.status,
  });

  final int id;
  final String name;
  final String location;
  final int capacity;
  final String status;

  factory Lab.fromJson(Map<String, dynamic> json) {
    return Lab(
      id: json['id'] as int? ?? 0,
      name: json['name'] as String? ?? '',
      location: json['location'] as String? ?? '',
      capacity: json['capacity'] as int? ?? 0,
      status: json['status'] as String? ?? '',
    );
  }
}

class LabRepository {
  LabRepository(this._dio);

  final Dio _dio;

  Future<List<Lab>> fetchLabs() async {
    final response = await _dio.get('/api/labs');
    final map = Map<String, dynamic>.from(response.data as Map);
    final list = (map['data'] as List<dynamic>? ?? const []);
    return list
        .map((item) => Lab.fromJson(Map<String, dynamic>.from(item as Map)))
        .toList();
  }

  Future<Lab> fetchLabDetail(int id) async {
    final response = await _dio.get('/api/labs/$id');
    return Lab.fromJson(
      Map<String, dynamic>.from(response.data as Map),
    );
  }
}

这一步和仓库后端是严格对应的,没有引入额外字段,因此更适合真实迁移。

3. 定义 Provider

import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

final dioProvider = Provider<Dio>((ref) {
  return Dio(
    BaseOptions(
      baseUrl: 'http://192.168.1.20:8000',
      connectTimeout: const Duration(seconds: 8),
      receiveTimeout: const Duration(seconds: 8),
      headers: const {
        'Content-Type': 'application/json',
      },
    ),
  );
});

final labRepositoryProvider = Provider<LabRepository>((ref) {
  return LabRepository(ref.watch(dioProvider));
});

final labListProvider = FutureProvider<List<Lab>>((ref) async {
  return ref.watch(labRepositoryProvider).fetchLabs();
});

final labDetailProvider = FutureProvider.family<Lab, int>((ref, id) async {
  return ref.watch(labRepositoryProvider).fetchLabDetail(id);
});

这里最关键的是 FutureProvider.family。它非常适合这个仓库里的详情接口,因为详情请求天然就是“按实验室 ID 拉取”的。

4. 列表页绑定状态

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

class LabListPage extends ConsumerWidget {
  const LabListPage({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
    final labsAsync = ref.watch(labListProvider);

    return Scaffold(
      appBar: AppBar(title: const Text('实验室列表')),
      body: labsAsync.when(
        data: (labs) {
          if (labs.isEmpty) {
            return const Center(child: Text('暂无可预约实验室'));
          }
          return RefreshIndicator(
            onRefresh: () async {
              await ref.refresh(labListProvider.future);
            },
            child: ListView.separated(
              itemCount: labs.length,
              separatorBuilder: (_, __) => const Divider(height: 1),
              itemBuilder: (context, index) {
                final lab = labs[index];
                return ListTile(
                  title: Text(lab.name),
                  subtitle: Text('${lab.location} · ${lab.capacity}人'),
                  trailing: Text(lab.status),
                  onTap: () {
                    Navigator.push(
                      context,
                      MaterialPageRoute<void>(
                        builder: (_) => LabDetailPage(labId: lab.id),
                      ),
                    );
                  },
                );
              },
            ),
          );
        },
        loading: () => const Center(child: CircularProgressIndicator()),
        error: (error, _) => Center(
          child: Padding(
            padding: const EdgeInsets.all(24),
            child: Column(
              mainAxisSize: MainAxisSize.min,
              children: [
                Text('加载失败:$error'),
                const SizedBox(height: 12),
                FilledButton(
                  onPressed: () => ref.invalidate(labListProvider),
                  child: const Text('重试'),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

这个写法的好处是明显的:UI 只描述三种状态,不直接处理网络细节,后期维护比把所有逻辑堆在 StatefulWidget 中轻松得多。

5. 详情页按需加载

class LabDetailPage extends ConsumerWidget {
  const LabDetailPage({super.key, required this.labId});

  final int labId;

  
  Widget build(BuildContext context, WidgetRef ref) {
    final detailAsync = ref.watch(labDetailProvider(labId));

    return Scaffold(
      appBar: AppBar(title: const Text('实验室详情')),
      body: detailAsync.when(
        data: (lab) => ListView(
          padding: const EdgeInsets.all(16),
          children: [
            ListTile(title: const Text('名称'), subtitle: Text(lab.name)),
            ListTile(title: const Text('位置'), subtitle: Text(lab.location)),
            ListTile(title: const Text('容量'), subtitle: Text('${lab.capacity}')),
            ListTile(title: const Text('状态'), subtitle: Text(lab.status)),
          ],
        ),
        loading: () => const Center(child: CircularProgressIndicator()),
        error: (error, _) => Center(
          child: Text('详情加载失败:$error'),
        ),
      ),
    );
  }
}

详情页不依赖列表页内部状态,而是自己按 ID 获取数据,这一点和仓库当前 GET /api/labs/{id} 的设计天然匹配。

6. 应用入口

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

void main() {
  runApp(
    const ProviderScope(
      child: MaterialApp(
        debugShowCheckedModeBanner: false,
        home: LabListPage(),
      ),
    ),
  );
}

对于 Flutter for OpenHarmony 项目来说,ProviderScope 放在最外层是比较稳妥的组织方式,后续新增用户状态、预约筛选条件、设备分类等 Provider 时也更容易扩展。

结合仓库业务后的落地价值

1. 列表页不再依赖“页面自身记忆”

当前仓库的 Vue 端预约页把不少逻辑放在页面组件里。到了 Flutter-OH,如果依旧照搬成单页大组件,代码会很快膨胀。Riverpod 的价值在于把“实验室列表是否已加载”“是否需要刷新”“详情是否需要重新获取”这些问题从 Widget 中抽出去。

2. 接口差异可以被 Provider 层消化

这个仓库的列表接口与详情接口返回结构不同:

  • 列表接口外层包了一层 data
  • 详情接口直接返回对象

如果没有统一的 Provider/Repository 分层,这些差异很容易散落到多个页面里。Riverpod 让这些解析逻辑集中在数据层,页面不需要重复判断。

3. 更适合后续扩展预约场景

当实验室预约提交接口、我的预约列表接口补齐后,可以继续沿用同一模式:

  • FutureProvider 管列表拉取
  • StateNotifierAsyncNotifier 管提交流程
  • 通过参数化 Provider 管理详情或筛选条件

这会比简单 setState 更适合真实项目演进。

鸿蒙化过程中容易遇到的问题

1. 刷新无效,其实是 Provider 没被刷新

不少开发者第一次用 Riverpod 时,会在下拉刷新里再次调用仓储方法,但没有刷新 Provider,本质上没有更新状态树。对于列表页,正确方式是:

await ref.refresh(labListProvider.future);

2. 真机能启动,但接口一直报超时

这类问题多半和 Riverpod 无关,而是网络地址配置不对。Flutter for OpenHarmony 在真机联调时,通常要把 baseUrl 设置成宿主机局域网 IP,不能直接写 localhost

3. 详情页频繁重复请求

如果页面反复进出详情页,可能会出现多次拉取。此时可以根据业务需要决定是否增加缓存策略,比如在 Provider 生命周期上做进一步管理,而不是一上来就把所有请求写死在页面跳转回调里。

4. 状态管理层过早复杂化

对于当前仓库这种刚开始做鸿蒙端迁移的项目,不建议一开始就引入过多抽象。先从 Provider + Repository + Model 三层结构起步,等预约提交、登录鉴权、筛选分页真正出现后,再考虑更复杂的状态架构。

结论

如果说 dio 解决的是“怎么请求”,那么 Riverpod 解决的就是“请求回来以后,页面状态如何组织”。在 LabManagementSystem 这种已有后端接口、业务对象稳定、未来还会继续增加预约与设备模块的项目中,Riverpod 非常适合作为 Flutter for OpenHarmony 客户端的状态管理基础。

更重要的是,这种实践不依赖平台特有能力,而是建立在纯 Dart 状态建模之上。这意味着它不仅适用于 OpenHarmony / HarmonyOS,也适合未来与 Android、iOS 共用一套 Flutter 业务层代码。对跨平台框架来说,这种“多端复用同一份状态组织方式”的收益,往往比单纯把页面跑起来更重要。

参考链接

Logo

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

更多推荐