1. 为什么新手在鸿蒙虚拟机上跑网络请求总翻车Flutter 鸿蒙新手实战里网络请求 列表展示是最容易“看起来简单、跑起来报错”的一环。你按教程把 dio 加进pubspec.yaml代码也抄得一字不差结果在鸿蒙虚拟机上点运行列表一直转圈控制台甩出一句SocketException: Connection failed或者干脆Permission denied。这不是你代码写错了而是鸿蒙系统对应用访问网络这件事管得比 Android 严得多——没在module.json5里声明网络权限请求会被系统直接拦掉连日志都不一定给你看全。另一个高频坑是 API Key 和请求地址的配置。很多新手习惯把 baseUrl 和密钥硬编码在api_service.dart里改一次要翻三个文件换台机器又得重来。更麻烦的是一旦密钥写死在代码里提交到仓库后面想换通道就得全局搜索替换漏一处就 401。这篇就围绕“Flutter 鸿蒙新手实战网络请求 列表展示一键跑通鸿蒙虚拟机”这条链路给你一套可复制的 TaoToken 统一 Key/API 通道配置骨架把配置抽到独立文件再配合鸿蒙虚拟机的验证动作让请求到列表渲染一次跑通。适合谁看刚接触 Flutter 鸿蒙跨平台、手里有 DevEco Studio 和鸿蒙虚拟机、想先把“能联网、能出列表”这个最小闭环跑通的新手。不需要你懂鸿蒙底层跟着配就行。2. TaoToken 前置把 Key 和通道抽成一份配置在动手写网络层之前先把“请求往哪发、用什么身份发”这件事从业务代码里剥出来。我试过把 baseUrl 和 Key 直接写在 service 里项目一多就乱后来统一改成读一份config文件改通道只动一个地方。TaoToken 在这里的角色是统一 API 通道你拿到一个 Key配一个 baseUrlFlutter 侧用标准 HTTP 客户端dio 或 http 都行按 OpenAI 兼容格式发请求即可。它不替代你的编辑器也不碰你的业务逻辑只负责把请求转发到目标模型服务。对新手来说好处是配置项少、格式统一不用为每个模型单独记一套鉴权方式。你需要先准备两样东西第一一个可用的 API Key。到控制台里创建复制出来先放一边别急着写进代码。创建入口在控制台的 API Keys 页面建议新建一个专门给这个 demo 用的 Key方便后面单独吊销。第二确认请求地址。TaoToken 的 API 入口是https://taotoken.net/api注意这里不带任何查询参数直接作为 baseUrl 的前缀使用。模型对话相关的调试可以在模型对话页面先手动发一条确认 Key 有效、通道通再回到 Flutter 里写代码能省掉一半排障时间。注意Key 属于敏感信息不要提交到公开仓库。下面我会用独立配置文件 占位符的方式你本地填真实值即可。如果你后面要做长期编码或 Agent 类项目可以了解下 Coding Plan它面向的是持续调用场景但本篇这个 demo 用普通 Key 就够了先把闭环跑通再说。3. 可复制配置config 文件 dio 封装 鸿蒙权限这一节是核心按顺序做每一步都能单独验证。3.1 项目结构与依赖先确认你的 Flutter 鸿蒙项目结构大致如下新手别把文件放错层demo1/ ├── ohos/ # 鸿蒙平台专属配置 ├── lib/ │ ├── config/ │ │ └── app_config.dart # 统一配置baseUrl Key │ ├── models/ │ │ └── post.dart # 数据模型 │ ├── services/ │ │ └── api_service.dart # 网络请求封装 │ └── main.dart # 入口 列表 UI └── pubspec.yaml在pubspec.yaml里加依赖dio 用稳定版即可dependencies: flutter: sdk: flutter dio: ^5.4.0保存后执行flutter pub get拉取依赖。3.2 独立 config 文件新建lib/config/app_config.dart把通道和 Key 集中管理class AppConfig { // TaoToken 统一 API 通道入口 static const String baseUrl https://taotoken.net/api; // 本地填入你的 Key不要提交真实值到仓库 static const String apiKey sk-你的Key填这里; // 请求超时 static const Duration connectTimeout Duration(seconds: 10); static const Duration receiveTimeout Duration(seconds: 10); }这样做的意义以后换 Key 或换通道只改这一个文件。业务代码里永远只引用AppConfig.baseUrl和AppConfig.apiKey。3.3 dio 封装与请求头新建lib/services/api_service.dart用单例模式封装 dio并把鉴权头统一加上import package:dio/dio.dart; import ../config/app_config.dart; class ApiService { static final ApiService _instance ApiService._internal(); factory ApiService() _instance; ApiService._internal(); static Dio? _dio; static Dio get dio { _dio ?? _initDio(); return _dio!; } static Dio _initDio() { final dio Dio( BaseOptions( baseUrl: AppConfig.baseUrl, connectTimeout: AppConfig.connectTimeout, receiveTimeout: AppConfig.receiveTimeout, headers: { Authorization: Bearer ${AppConfig.apiKey}, Content-Type: application/json, }, ), ); // 开发期打开日志方便看请求和响应 dio.interceptors.add(LogInterceptor(responseBody: true)); return dio; } // 示例拉取列表数据按你的实际接口路径调整 static FutureListdynamic getPostList() async { final response await dio.get(/posts); return response.data; } }关键点Authorization头用Bearer前缀这是 OpenAI 兼容格式的通用写法。baseUrl 末尾不要带斜杠接口路径以/开头dio 会正确拼接。3.4 数据模型新建lib/models/post.dart把 JSON 转成强类型对象class Post { final int id; final String title; final String body; Post({required this.id, required this.title, required this.body}); factory Post.fromJson(MapString, dynamic json) { return Post( id: json[id] ?? 0, title: json[title] ?? 无标题, body: json[body] ?? 无内容, ); } static ListPost fromJsonList(Listdynamic jsonList) { return jsonList.map((json) Post.fromJson(json)).toList(); } }3.5 鸿蒙网络权限必做这一步不做前面全白搭。打开ohos/entry/src/main/module.json5在requestPermissions数组里加{ name: ohos.permission.INTERNET, reason: 应用需要访问网络获取列表数据, usedScene: { abilities: [EntryAbility], when: inuse } }保存。鸿蒙对权限声明是强校验的缺了这条dio 发出的请求会被系统层拦截表现就是超时或连接失败。4. 验证请求从虚拟机跑通到列表渲染配置齐了现在写 UI 并验证。4.1 主页面三态渲染修改lib/main.dart实现加载中、加载失败、加载成功三种状态import package:flutter/material.dart; import models/post.dart; import services/api_service.dart; void main() runApp(const MyApp()); class MyApp extends StatelessWidget { const MyApp({super.key}); override Widget build(BuildContext context) { return MaterialApp( title: 鸿蒙Flutter实战, theme: ThemeData(primarySwatch: Colors.purple), home: const PostListPage(), debugShowCheckedModeBanner: false, ); } } class PostListPage extends StatefulWidget { const PostListPage({super.key}); override StatePostListPage createState() _PostListPageState(); } class _PostListPageState extends StatePostListPage { ListPost postList []; bool isLoading true; String? errorMsg; override void initState() { super.initState(); loadData(); } Futurevoid loadData() async { setState(() { isLoading true; errorMsg null; }); try { final jsonData await ApiService.getPostList(); setState(() { postList Post.fromJsonList(jsonData); isLoading false; }); } catch (e) { setState(() { errorMsg e.toString(); isLoading false; }); } } override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text(开发者社区), centerTitle: true), body: buildBody(), floatingActionButton: FloatingActionButton( onPressed: loadData, child: const Icon(Icons.refresh), ), ); } Widget buildBody() { if (isLoading) { return const Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ CircularProgressIndicator(), SizedBox(height: 16), Text(数据加载中..., style: TextStyle(color: Colors.grey)), ], ), ); } if (errorMsg ! null) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Icon(Icons.error_outline, color: Colors.red, size: 48), const SizedBox(height: 16), Text(errorMsg!, textAlign: TextAlign.center), const SizedBox(height: 20), ElevatedButton(onPressed: loadData, child: const Text(点击重试)), ], ), ); } return ListView.builder( padding: const EdgeInsets.all(12), itemCount: postList.length, itemBuilder: (context, index) { final post postList[index]; return Padding( padding: const EdgeInsets.only(bottom: 12), child: Card( child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( post.title, style: const TextStyle( fontWeight: FontWeight.bold, fontSize: 16), maxLines: 2, ), const SizedBox(height: 8), Text( post.body, style: const TextStyle(color: Colors.grey), maxLines: 3, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 8), Align( alignment: Alignment.bottomRight, child: Text( 帖子ID${post.id}, style: const TextStyle(fontSize: 12, color: Colors.grey), ), ), ], ), ), ), ); }, ); } }4.2 构建 HAP 并安装到虚拟机终端进入项目的ohos目录执行构建hvigorw assembleHap -p productdefault -p buildModedebug看到BUILD SUCCESS说明打包成功。接着安装到已启动的鸿蒙虚拟机hdc install -r entry/build/default/outputs/default/entry-default-unsigned.hap然后启动应用hdc shell aa start -a EntryAbility -b com.example.demo14.3 成功结果长什么样应用启动后你会先看到居中的转圈和“数据加载中...”大约一两秒后列表出现每条是一个卡片标题加粗、正文灰色截断、右下角显示帖子 ID。点右下角悬浮刷新按钮会重新走一遍加载流程。如果接口返回正常列表会稳定渲染如果 Key 或通道有问题会进入错误态并显示具体异常点“点击重试”可重新请求。这一步跑通说明 Flutter 鸿蒙网络请求 列表展示的完整链路已经打通。5. 本篇常见错排查下面这些是我和身边新手实际踩过的按出现频率排序。报错一SocketException: Connection failed或请求一直超时先查module.json5里的ohos.permission.INTERNET有没有加、拼写对不对、abilities是否指向EntryAbility。鸿蒙权限声明错一个字母都会静默失败。其次确认虚拟机本身能联网可以在虚拟机里打开浏览器试一下。报错二401 Unauthorized九成是 Key 的问题。检查AppConfig.apiKey是否填了真实值、有没有多余空格、Bearer前缀和 Key 之间是否只有一个空格。另外确认 Key 没有过期或被吊销。可以先去模型对话页面手动发一条确认 Key 本身可用再回来查代码。报错三FormatException或列表渲染时崩溃接口返回的结构和你Post.fromJson里取的字段对不上。用LogInterceptor把responseBody打出来看真实返回的 JSON 长什么样再调整字段名。别凭记忆写字段。报错四hdc install报设备未连接虚拟机要先启动hdc list targets能看到设备再安装。如果列表为空检查 DevEco Studio 里虚拟机是否处于运行状态。报错五构建成功但应用启动白屏多半是EntryAbility的 bundleName 和你aa start时写的-b参数不一致。以module.json5里的 bundleName 为准。提示排障时优先看LogInterceptor打出的请求 URL、请求头和响应体比盲猜快得多。接入相关的细节可以对照接入文档逐项核对。6. 把配置骨架留下来下次直接复用这套东西跑通之后真正值钱的不是那段列表 UI而是app_config.dartapi_service.dart这个组合。下次开新项目把这两个文件复制过去改一下 baseUrl 和 Key网络层就齐了。鸿蒙权限那段module.json5也存成模板新项目直接贴。如果你后面要接更多模型或做持续调用Key 的管理建议走控制台的 API Keys 页面统一创建和吊销别在多个项目里散落同一把 Key。需要长期编码或 Agent 场景的可以看下 Coding Plan 的适用方式只是验证模型通不通模型对话页面最快。接入过程中遇到请求格式或鉴权问题接入文档里有完整的请求示例可以对照。最后留一个实用习惯每次改完配置先在模型对话里手动发一条确认通道通再回 Flutter 跑虚拟机。这一步花三十秒能帮你排除掉大半“到底是代码问题还是配置问题”的纠结。
