Flutter GetX控制器首次加载接收null而非数据问题排查
Flutter GetX 首次加载API数据为null,刷新后正常的问题排查
问题现象
使用GetX Controller发起GET API请求并跳转新页面后,首次加载时模型接收的是null值,但刷新页面后数据能正确获取并展示,调试日志提示Null check operator used on a null value。
Controller代码
import 'package:app/modules/community/models/post_details_model.dart' as PostDetailsModel; import 'package:get/get.dart'; import '../../../services/dio/api_service.dart'; class PostDetailsController extends GetxController { PostDetailsModel.Data postData = PostDetailsModel.Data(); final postID = Get.arguments; @override void onInit() { fetchPostDetails(postID); super.onInit(); } fetchPostDetails(postID) async { try { postData = (await APIManager.getPostDetails(postID.toString()))!; } catch (error) { print('List fetch error => $error'); } } }
View代码
import 'package:app/helpers/component/custom_elevated_fill_button.dart'; import 'package:app/modules/community/controllers/post_details_controller.dart'; import 'package:app/utils/alfnr_icons.dart'; import 'package:app/utils/app_colors.dart'; import 'package:app/utils/text_style_util.dart'; import 'package:another_carousel_pro/another_carousel_pro.dart'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:get/get.dart'; class PostDetailsView extends StatefulWidget { const PostDetailsView({Key? key}) : super(key: key); @override State<PostDetailsView> createState() => _PostDetailsViewSate(); } class _PostDetailsViewSate extends State<PostDetailsView> { @override Widget build(BuildContext context) { PostDetailsController controller = Get.put(PostDetailsController()); return Scaffold( backgroundColor: ColorUtil.appColorWhite, body: SafeArea( child: GestureDetector( onTap: () => FocusManager.instance.primaryFocus?.unfocus(), child: Stack( children: [ SizedBox( height: 300.0, width: double.infinity, child: AnotherCarousel( images: [ if (controller.postData.thumbnails != null) for (var image in controller.postData.thumbnails!) NetworkImage(image) ], ), ), CustomScrollView( shrinkWrap: true, slivers: <Widget>[ SliverAppBar( pinned: true, snap: false, floating: false, expandedHeight: 230.h, backgroundColor: Colors.transparent, flexibleSpace: FlexibleSpaceBar( background: AnotherCarousel( images: [ if (controller.postData.thumbnails != null) for (var image in controller.postData.thumbnails!) NetworkImage(image) ], ), ), ), SliverFillRemaining( child: SingleChildScrollView( physics: const NeverScrollableScrollPhysics(), child: Container( height: 1200, color: Colors.white, padding: EdgeInsets.all(15.h), alignment: Alignment.center, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(controller.postData.categoryTitle!, style: TextStyle( fontSize: 20.sp, fontWeight: FontWeight.w500, color: const Color(0xff0A496B), fontFamily: AppFont.sfProDisplayFontFamily)), const SizedBox(height: 10), Text('Location: ${controller.postData.location}'), ], ), ), ), ), ], ), Align( alignment: Alignment.bottomCenter, child: Container( color: Colors.white, padding: EdgeInsets.symmetric(vertical: 20.h, horizontal: 20.w), child: CustomElevatedFillButton( buttonText: 'View 25 Subscriptions', onTap: () => {}, ), ), ), Container( padding: EdgeInsets.only(top: kToolbarHeight), child: Row( mainAxisAlignment: MainAxisAlignment.end, children: [ Column( children: [ Container( decoration: BoxDecoration( color: const Color(0xffE85175), borderRadius: BorderRadius.circular(6)), margin: EdgeInsets.only(bottom: 10, right: 10), child: Material( color: Colors.transparent, child: InkWell( onTap: () {}, child: const Padding( padding: EdgeInsets.all(10), child: Icon(Alfnr.delete, color: Colors.white, size: 18), ), ), ), ), Container( decoration: BoxDecoration( color: const Color(0xff16A9FA), borderRadius: BorderRadius.circular(6)), margin: EdgeInsets.only(bottom: 10, right: 10), child: Material( color: Colors.transparent, child: InkWell( onTap: () { // Get.toNamed(Routes.EDIT_SERVICES, // arguments: {"serviceId": postModel.postID}); }, child: const Padding( padding: EdgeInsets.all(10), child: Icon(Alfnr.edit, color: Colors.white, size: 18), ), ), ), ) ], ) ], )) ], ), ), ), ); } @override void dispose() { Get.delete<PostDetailsController>(); super.dispose(); } }
Binding代码
import 'package:app/modules/community/controllers/post_details_controller.dart'; import 'package:get/get.dart'; class PostDetailsBindings extends Bindings { @override void dependencies() { Get.lazyPut(() => PostDetailsController()); } }
API Service代码
static Future<PostDetailsModel.Data?> getPostDetails(postID) async { String token = Get.find<GetStorageService>().token; Response<dynamic> response = await DioClient( isOverlayLoader: false, ).get( Endpoints.getCommunityPostDetails(postID), options: Options( headers: { 'Authorization': "Bearer $token", }, ), ); if (response.statusCode == 200) { return PostDetailsModel.postDetailsResponseModelFromJson(response.data.toString()).data; } else { return null; } }
调试错误日志
[GETX] GOING TO ROUTE /POSTS_DETAILS [GETX] Instance "PostDetailsController" has been created [GETX] Instance "PostDetailsController" has been initialized Another exception was thrown: Null check operator used on a null value
问题根源及修复方案
核心问题
- 状态未响应式更新:
postData不是Rx类型,API请求完成后更新数据不会通知View刷新,首次加载时View拿到的是初始空对象,后续刷新时数据已缓存所以能显示。 - 空值强制解包风险:代码中使用
!强制解包categoryTitle和API返回值,数据未加载或API返回null时触发空指针错误。 - View重复创建Controller:
build方法中调用Get.put()会每次重建Widget时重新创建Controller,与Binding的lazyPut冲突,应使用Get.find()获取已绑定的实例。
修复步骤
1. 修改Controller为响应式状态
import 'package:app/modules/community/models/post_details_model.dart' as PostDetailsModel; import 'package:get/get.dart'; import '../../../services/dio/api_service.dart'; class PostDetailsController extends GetxController { // 使用Rxn可空响应式对象,自动通知View更新 final postData = Rxn<PostDetailsModel.Data>(); final postID = Get.arguments; @override void onInit() { fetchPostDetails(postID); super.onInit(); } fetchPostDetails(postID) async { try { final data = await APIManager.getPostDetails(postID.toString()); postData.value = data; } catch (error) { print('List fetch error => $error'); } } }
2. 修改View,监听状态并避免空值解包
class _PostDetailsViewSate extends State<PostDetailsView> { @override Widget build(BuildContext context) { // 获取Binding中已注册的Controller,避免重复创建 PostDetailsController controller = Get.find<PostDetailsController>(); return Scaffold( backgroundColor: ColorUtil.appColorWhite, body: SafeArea( child: GestureDetector( onTap: () => FocusManager.instance.primaryFocus?.unfocus(), child: Obx(() { // 数据未加载时显示加载状态 if (controller.postData.value == null) { return const Center(child: CircularProgressIndicator()); } final post = controller.postData.value!; return Stack( children: [ SizedBox( height: 300.0, width: double.infinity, child: AnotherCarousel( images: post.thumbnails?.map((url) => NetworkImage(url)).toList() ?? [], ), ), CustomScrollView( shrinkWrap: true, slivers: <Widget>[ SliverAppBar( pinned: true, snap: false, floating: false, expandedHeight: 230.h, backgroundColor: Colors.transparent, flexibleSpace: FlexibleSpaceBar( background: AnotherCarousel( images: post.thumbnails?.map((url) => NetworkImage(url)).toList() ?? [], ), ), ), SliverFillRemaining( child: SingleChildScrollView( physics: const NeverScrollableScrollPhysics(), child: Container( height: 1200, color: Colors.white, padding: EdgeInsets.all(15.h), alignment: Alignment.center, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 空值运算符替代强制解包 Text(post.categoryTitle ?? '无分类', style: TextStyle( fontSize: 20.sp, fontWeight: FontWeight.w500, color: const Color(0xff0A496B), fontFamily: AppFont.sfProDisplayFontFamily)), const SizedBox(height: 10), Text('Location: ${post.location ?? '未填写'}'), ], ), ), ), ), ], ), // 其余UI组件保持不变 ], ); }), ), ), ); } @override void dispose() { // 通过Binding注册的Controller无需手动删除,GetX自动管理生命周期 super.dispose(); } }
3. 确保路由绑定生效
跳转页面时使用GetX路由并绑定Binding,或在路由配置中绑定:
// 方式1:跳转时绑定 Get.toNamed('/POSTS_DETAILS', arguments: postId, binding: PostDetailsBindings()); // 方式2:路由配置中绑定 GetPage( name: '/POSTS_DETAILS', page: () => const PostDetailsView(), binding: PostDetailsBindings(), ),
4. 优化API Service错误处理
非200状态时抛出异常,便于统一错误捕获:
static Future<PostDetailsModel.Data> getPostDetails(postID) async { String token = Get.find<GetStorageService>().token; Response<dynamic> response = await DioClient( isOverlayLoader: false, ).get( Endpoints.getCommunityPostDetails(postID), options: Options( headers: { 'Authorization': "Bearer $token", }, ), ); if (response.statusCode == 200) { return PostDetailsModel.postDetailsResponseModelFromJson(response.data.toString()).data!; } else { throw Exception('获取帖子详情失败:${response.statusCode}'); } }
内容的提问来源于stack exchange,提问作者Jamal Ahmad
相关产品推荐
相关产品推荐

