《Flutter性能优化全攻略:从首屏渲染到性能监测,附案例代码详解》

性能优化(App)

前言

随着移动端开发的发展,用户对 App 性能的要求越来越高,首屏加载的流畅度、长列表的加载速度、动画的统一性、甚至异常日志的监控,都是开发中需要关注的重点。

本文将全面总结遇到的一些问题并分享 Flutter 性能优化的实践经验,提供了部分详实的案例代码,覆盖从渲染优化到数据模型再到网络请求的全链路优化。

1. 渲染层面

1.1 首屏渲染

首屏渲染性能直接影响用户的初次体验。可以通过以下措施优化:

优化方法:
  • 进度条弹框
    • 在首页初始化时,弹出全屏进度条,同时在后台完成数据加载和组件初始化。
  • 骨架屏
    • 替代进度条的另一种方案,使用占位符组件(如 SkeletonLoader)模拟内容框架,避免空白屏。
  • 预加载静态资源
    • 在进入首页之前预加载轮播图、Banner 等组件。
代码演示
  • 我这里的思路是在请求首页分类的接口时引用全局的loading状态(长列表的加载会在后面讲解)
  • 其他的轮播图和一些静态资源全部使用预加载机制
dart 复制代码
class _HomeState extends State<Home>  {
  // 开启banner 静态图片的预加载
  bool _imagesPrecached = false;
  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    // 确保只预加载一次
    if (!_imagesPrecached) {
      _precacheImages();
      _imagesPrecached = true;
    }
  }

  Future<void> _precacheImages() async {
    // 使用 context 安全预加载图片
    await Future.wait([
      precacheImage(const AssetImage('${AppEnumAssets.basePath}banner_ai.png'), context),
      precacheImage(const AssetImage('${AppEnumAssets.basePath}banner_pj.png'), context),
      precacheImage(const AssetImage('${AppEnumAssets.basePath}banner_qa.png'), context),
    ]);
  }

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context).extension<AppColors>()!;
    return StatusBarWrapper(
      statusBarColor: theme.subjectMix1!,
      statusBarIconBrightness: Theme.of(context).brightness == Brightness.dark ? Brightness.light : Brightness.dark,
      child: SafeArea(
        child: Column(
          children: [
            Container(
              color: theme.subjectMix1,
              padding: const EdgeInsets.only(left: 16 , right: 16 ,bottom: 16),
              child: Column(
                children: [
                  Row(
                    mainAxisAlignment: MainAxisAlignment.spaceBetween,
                    children: [
                      HomeScan(theme: theme),
                      // 让 SearchBox 占满剩余空间
                      const Expanded( 
                        child: SearchBox(),
                      ),
                      const ClockIn()
                    ],
                  ),
                  const HomeBanner(),
                  const HomeCalendar(),
                ],
              ),
            ),
            Expanded(
              child: Container(
                padding: const EdgeInsets.only(bottom:8),
                color: theme.subjectPure,
                child: const HomeCategory()
              )
            )
          ],
        )
      )
    );
  }
}
  • HomeCategoryState 代码
dart 复制代码
class _HomeCategoryState extends State<HomeCategory> with SingleTickerProviderStateMixin {
  late TabController _tabController;
  int currentIndex = 0; // 当前选择试题频道
  List<QuestionType> categoryList = []; // 用于存储分类数据
  bool _isLoading = true; // 控制加载状态
  int sort = 0; // 当前筛选分类
  bool isInitialLoading = true; // 新增的标志,用于判断是否是初始加载  登录成功后不显示loading

  @override
  void initState()  {
    super.initState();
    _initializeData(isInitialLoading: true);
  }

  /// 初始化试题分类数据的方法
  Future<void> _initializeData({bool isInitialLoading = false}) async {
    final progressNotifier = ValueNotifier<double>(0.0);
    if(isInitialLoading){
      /*   显示加载框并绑定进度通知器
      WidgetsBinding.instance.addPostFrameCallback:  addPostFrameCallback 保证加载框的逻辑不会因为频繁触发 build() 而多次插入
      用于在当前帧的构建完成后执行一段逻辑,避免在组件构建过程中直接对 UI 产生副作用(如操作 Overlay 等)。
      在这里用于异步控制加载框的显示和隐藏,而不是让构建逻辑直接处理这些操作。
      */
      WidgetsBinding.instance.addPostFrameCallback((_) {
        PageLoadOverlay.show(context: context);
      });
    }
    
    try {
      // 获取分类数据
      final questionTypes = await fetchQuestionTypes(context);
      setState(() {
        categoryList = questionTypes;
        _isLoading = false;
        _tabController = TabController(length: categoryList.length, vsync: this);
      });
    } catch (e) {
      Logger.error('报错 $e', tag: "接口请求失败");
      setState(() {
        _isLoading = false; // 请求失败也停止加载
      });
    }finally {
      progressNotifier.dispose();
      if(isInitialLoading){
        // 隐藏加载框
        WidgetsBinding.instance.addPostFrameCallback((_) {
          PageLoadOverlay.hide();
        });
      }
    }
  }
  ...
 @override
 Widget build(BuildContext context) {
    final theme = Theme.of(context);
    // 避免在build函数中 使用 addPostFrameCallback  
    if (_isLoading) {
      // 返回一个空容器,因为加载框是通过 Overlay 控制的  SizedBox.shrink() 是一个非常轻量的组件,几乎不占用任何渲染资源。  这样做的效果 降低首桢渲染200ms
      // 也可以做成骨架屏 但是骨架屏本身也会占用渲染资源
      return const SizedBox.shrink();
    }
    if (categoryList.isEmpty) {
      return const PageEmpty();
    }
    return Consumer<AppRefreshProvider>(
      builder: (context, refreshNotifier , child) { 
        if(refreshNotifier.shouldRefresh && mounted){
          WidgetsBinding.instance.addPostFrameCallback((_) {
          });
        }
        return Column(
          children: [
            // 顶部 TabBar 和筛选按钮
            CategoryTabbar(
              categoryList: categoryList,
              theme: theme,
              tabController: _tabController,
              onTabTapped: onTabTapped,
              onFilterTapped: () {
                // 处理筛选逻辑
                filterTapped(theme);
              },
            ),
            Expanded(
              child: TabBarView(
                controller: _tabController,
                children: categoryList.map((category) {
                  return QuestionList(
                    typeId:category.id,
                    sort:sort,
                  );
                }).toList(),
              ),
            ),
          ],
        );
      },
    );
  }
}
总结
  • 在应用首页需要初始化请前唤起进度条弹框并使用单独的私有变量_isloading进行标记(默认是true,请求完成且客户端处理完成后变成false),在build函数进行判断,若_isLoading为true时,展示一个空容器(空容器SizedBox.shrink() 基本不占用任何渲染资源;也可以使用骨架屏,但就不需要进度条弹框,两种方案选一种),待客户端处理接口完成后,展示真正需要渲染的Widget组件。
  • 首页一些需要展示的静态组件,(轮播图、banner等),在进入Home 前就开始使用预加载,不管是静态图还是网络图片,都最大程度上保证加载速度。

1.2 长列表

优化方案
  • 使用 SmartRefresher(支持下拉刷新和上拉加载)。
    • 如果是分类列表(可左右滑动切换分类,采用TabBar+TabBarView,没有分类可用PageView
  • GetxController 中维护分类状态。
    • 建议不要只维护一个RxList,需要重复的替换、覆盖,利用率不高,选择维护一个RxMap,维护每一个分类和list列表
  • 缓存页面状态,避免重复加载。
    • 为了避免每次切花分类都需要重新渲染Widget
代码演示
  • controller层
dart 复制代码
class OptState {
  final RxList<Question> optList = <Question>[].obs;
  final RxBool isFinished = false.obs; // 是否加载完成
  final RxBool isLoading = false.obs; // 是否正在加载中
  int currentPage = 1; // 当前页码
  int pageTotal = 1; // 总页数
  void reset() {
    optList.clear();
    isFinished.value = false;
    currentPage = 1;
  }
}

class OptlistController {
  final RxMap<int , OptState> optState = <int , OptState>{}.obs;
  OptState getPageState(int optType) {
    optState.putIfAbsent(optType, () => OptState());
    return optState[optType]!;
  }
  Future<void> _fetchOptLists({
    required BuildContext context,
    required int optType,
    required int questionBankType,
    required OptState state
  }) async {
    try {
      final params = OptListParams(
        optType: optType, 
        pageSize: 10, 
        page: state.currentPage, 
        questionBankType: questionBankType
      );
      final result = await fetchOptList(params, context);
       // 处理结果
      if (result.questions.isNotEmpty) {
        state.optList.addAll(result.questions);
        state.pageTotal = result.pageTotal;
        state.currentPage++;
        state.isFinished.value = state.currentPage > state.pageTotal;
      } else {
        state.isFinished.value = true;
      }
    } catch (e) {
      Logger.error("加载optlist失败: $e", tag: "fetchQuestions");
    }
  }

  // 加载更多数据
  Future <void> loadOptList({
    required BuildContext context,
    required int optType,
    required int questionBankType,
  })async {
    final state = getPageState(optType);
    if(state.isLoading.value || state.isFinished.value) return ;
    state.isLoading.value = true;
    await _fetchOptLists(
      context: context, 
      optType: optType, 
      questionBankType: questionBankType, 
      state: state
    );
    state.isLoading.value = false;
  }
  // 刷新数据
  Future<void> refreshOptList({
    required BuildContext context,
    required int optType,
    required int questionBankType,
  }) async {
    final state = getPageState(optType);
    if (state.isLoading.value) return;
    state.isLoading.value = true;
    state.reset();
    await _fetchOptLists(
      context: context, 
      optType: optType, 
      questionBankType: 
      questionBankType, 
      state: state
    );
    state.isLoading.value = false;
  }
}
  • 列表组件层
dart 复制代码
class _OptListState extends State<OptList> with AutomaticKeepAliveClientMixin {
  @override
  bool get wantKeepAlive => true; // 保持页面状态
  // 下拉刷新和上拉加载控制器
  final RefreshController _refreshController = RefreshController(initialRefresh: false);
  final OptlistController optlistController = Get.put(OptlistController());
  late OptState _optState;

  @override
  void initState() {
    super.initState();
    _optState = optlistController.getPageState(widget.optType);
    WidgetsBinding.instance.addPostFrameCallback((_) {

      _loadMore();
    });
  }

  // 模拟加载更多数据
  void _loadMore() async {
    await optlistController.loadOptList(
      context: context, 
      optType: widget.optType, 
      questionBankType: 10
    );
    if(_optState.isFinished.value){
      _refreshController.loadNoData();
    }else{
      _refreshController.loadComplete();
    }
  }

  // 模拟刷新数据
  void _onRefresh() async {
    _refreshController.resetNoData();
    await optlistController.refreshOptList(
      context: context, 
      optType: widget.optType, 
      questionBankType: 10 
    );
    _refreshController.refreshCompleted();
  }

  @override
  Widget build(BuildContext context) {
    super.build(context); 
    return  Obx((){
      final optList = _optState.optList;
      return SmartRefresher(
        controller: _refreshController,
        enablePullDown: true,
        enablePullUp: true,
        header: const ListCustomHeader(),
        footer:const ListCustomFooter(),
        onRefresh: _onRefresh,
        onLoading: _loadMore,
        child: optList.isEmpty?
          ( _optState.isFinished.value ? const PageEmpty(): const PageLoad(width: 2)):
          MasonryGridView.count(
          crossAxisCount: 2, // 两列
          mainAxisSpacing: 4, // 垂直间距
          crossAxisSpacing: 4, // 水平间距
          padding: EdgeInsets.symmetric(horizontal: 8),
          itemCount: optList.length,
          itemBuilder: (context, index) {
            final item = optList[index];
            return OptItem(
              id:item.id,
              ids:optList.map((e) => e.id).toList(),
              theme: widget.theme, 
              stem: item.stem, 
              subjectName: item.subjectName!, 
              creatorName: item.creatorName!, 
              createdAt: item.createdAt!, 
              creatorAvatar: item.creatorAvatar!, 
              difficulty: item.difficulty,
              questionType:item.questionType!
            );
          },
        )
      );
    });
  }
}
思路解释
  • 每个Map 是由分类的唯一标识 + State维护,State包括list(需要渲染的数据源)、isLoading(是否在加载中的标记)、isFinished(当前分类是否加载完毕的标记)、currentPage(当前页)、pageTotal(总页数或总数量,最好要求服务端返回)。
  • 长列表数据的请求方法有两个 refresh刷新和loadMore加载,区别:loadMore专注于加载更多,触发时在isLoadingisFinishedtrue时都需要return,避免触发过于频繁,之后发起网络请求前让isLoadingtrue,请求完成后isLoadingfalserefresh专注于刷新,执行前先把list请空、currentPage改为0,isFinished改为false,之前再发起网络请求;每次网络请求发起后,都去把对应Statelist补充上,判断当前页是否是最后一页,如果不是让当前页++,如果是让isFinished变成true
  • 渲染时,使用AutomaticKeepAliveClientMixin 缓存页面状态,初始化时先去加载当前的State,然后渲染当前的Statelist,遇到isLoading那么展示加载中的动画,同时处理_refreshController控制器,对加载完成、加载完毕,执行对应的方法,展示完全定制的HeaderFooter

1.3 多图片、多视频

优化方案
  • 多图片场景:
    • 使用 CachedNetworkImage 实现图片的懒加载与缓存处理。
    • 对大规模图片列表,研究使用 flutter_staggered_grid_view 等库以优化图片的布局和加载性能。
  • 多视频场景:
    • 视频播放器推荐使用 video_player 或基于其封装的库(如 chewie),可以支持预加载、暂停、恢复等功能。
    • 如果是长视频列表,可以结合 PreloadPageView 实现预加载,同时动态释放不需要的视频资源,避免内存占用过高。

1.4 应用动画

优化方案
  • 将应用的弹框唤起、隐藏,页面路由切换等尽量统一,避免动画写的零碎,增加渲染成本
  • 写一个通用的动画类(一个类型的组件都放在一个动画壳子,具体的内容widget 由外部传递)
代码演示
  • 动画壳(针对弹出框、各种提示)
    • 同时配置一些常用的功能
    • 蒙层是否展示、是否支持点击蒙层关闭、动画起止位置、关闭动画暴露给外部、展示位置等
dart 复制代码
class AppAnimatedOverlay extends StatefulWidget {
  final Widget content; // 动态内容
  final VoidCallback onClose; // 动画结束后关闭 Overlay 的回调
  final Duration duration; // 动画持续时间
  final Offset beginOffset; // 动画起始位置
  final Offset endOffset; // 动画结束位置
  final bool dismissOnBackgroundTap; // 是否允许点击蒙层关闭
  final bool showOverlay; // 是否显示蒙层
  final String? position; // 位置:'top'、'bottom' 或 null(居中)
  final Offset? customPosition; // 自定义坐标(优先级高于 'top' 和 'bottom')
  const AppAnimatedOverlay({
    required this.content,
    required this.onClose,
    this.duration = const Duration(milliseconds: 300),
    this.beginOffset = const Offset(1.0, 0.0), // 从右侧屏幕外开始
    this.endOffset = Offset.zero, // 到屏幕内
    this.dismissOnBackgroundTap = true, // 默认允许点击蒙层关闭
    this.showOverlay = true, // 默认显示蒙层
    this.position, // 位置:'top'、'bottom' 或 null
    this.customPosition, // 自定义坐标
    Key? key,
  }) : super(key: key);
  /// 提供一个静态方法供外部触发关闭
  static late _AppAnimatedOverlayState? _overlayState;
  static void close() {
    _overlayState?._closeOverlay(); // 调用内部的关闭逻辑
  }
  @override
  _AppAnimatedOverlayState createState() {
    _overlayState = _AppAnimatedOverlayState(); // 保存 state 的引用
    return _overlayState!;
  }
}
class _AppAnimatedOverlayState extends State<AppAnimatedOverlay>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<Offset> _offsetAnimation;
  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      vsync: this,
      duration: widget.duration,
    );

    _offsetAnimation = Tween<Offset>(
      begin: widget.beginOffset,
      end: widget.endOffset,
    ).animate(CurvedAnimation(
      parent: _controller,
      curve: Curves.easeInOut,
    ));

    // 开始播放进入动画
    _controller.forward();
  }
  /// 内部方法:播放反向动画并关闭 Overlay
  void _closeOverlay() {
    _controller.reverse().then((_) {
      // 动画结束后触发 onClose 回调
      widget.onClose();
    });
  }
  @override
  void dispose() {
    _controller.dispose();
    AppAnimatedOverlay._overlayState = null; // 清除静态引用
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    // 根据位置动态渲染内容
    Widget contentWidget;
    if (widget.customPosition != null) {
      // 自定义坐标
      contentWidget = Positioned(
        left: widget.customPosition!.dx,
        top: widget.customPosition!.dy,
        child: SlideTransition(
          position: _offsetAnimation,
          child: Material(
            color: Colors.transparent,
            child: widget.content, // 动态内容
          ),
        ),
      );
    } else if (widget.position == 'top') {
      // 顶部
      contentWidget = Align(
        alignment: Alignment.topCenter, // 底部居中对齐
        child: Padding(
          padding: EdgeInsets.only(top: 50.0), // 设置底部偏移量
          child: SlideTransition(
            position: _offsetAnimation,
            child: Material(
              color: Colors.transparent,
              child: widget.content, // 动态内容
            ),
          ),
        ),
      );
    } else if (widget.position == 'bottom') {
      // 底部
      contentWidget = Align(
        alignment: Alignment.bottomCenter, // 底部居中对齐
        child: Padding(
          padding: EdgeInsets.only(bottom: 50.0), // 设置底部偏移量
          child: SlideTransition(
            position: _offsetAnimation,
            child: Material(
              color: Colors.transparent,
              child: widget.content, // 动态内容
            ),
          ),
        ),
      );
    } else {
      // 居中(默认)
      contentWidget = Center(
        child: SlideTransition(
          position: _offsetAnimation,
          child: Material(
            color: Colors.transparent,
            child: widget.content, // 动态内容
          ),
        ),
      );
    }
    return Stack(
      children: [
        // 蒙层
        if (widget.showOverlay)
          GestureDetector(
            behavior: HitTestBehavior.translucent, // 捕获点击
            onTap: widget.dismissOnBackgroundTap ? _closeOverlay : null, // 配置是否允许点击关
            child: Container(
              width: double.infinity,
              height: double.infinity,
              color: Colors.black.withOpacity(0.3), // 蒙层颜色
            ),
          ),
        // 动画内容
        contentWidget,
      ],
    );
  }
}
  • 设置主题弹框
dart 复制代码
class SettingThemeOverlay {
  static void show(BuildContext context) {
    final provider = context.read<ThemeProvider>();
    OverlayState overlayState = Overlay.of(context);
    OverlayEntry? overlayEntry;

    overlayEntry = OverlayEntry(
      builder: (context) {
        return AppAnimatedOverlay(
          onClose: () {
            overlayEntry!.remove();
          },
          content: _buildThemeContent(context, provider), // 动态内容
        );
      },
    );

    overlayState.insert(overlayEntry);
  }

  static Widget _buildThemeContent(BuildContext context, ThemeProvider provider) {
    return Container(
      width: MediaQuery.of(context).size.width * 0.88,
      decoration: BoxDecoration(
        color: Theme.of(context).cardColor,
        borderRadius: BorderRadius.circular(12),
      ),
      padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          _buildSwitchTile(context, provider),
          AppRippleButton(
            onTap: () {
              provider.switchTheme("light");
            },
            padding: const EdgeInsets.only(right: 4, top: 12, bottom: 12),
            child: _buildOptionTile(
              title: '浅色模式',
              isSystem: provider.themeMode == ThemeMode.system,
              isSelect: provider.themeMode == ThemeMode.light,
            ),
          ),
          AppRippleButton(
            onTap: () {
              provider.switchTheme("dark");
            },
            padding: const EdgeInsets.only(right: 4, top: 12, bottom: 12),
            child: _buildOptionTile(
              title: '深色模式',
              isSystem: provider.themeMode == ThemeMode.system,
              isSelect: provider.themeMode == ThemeMode.dark,
            ),
          ),
        ],
      ),
    );
  }

  static Widget _buildSwitchTile(BuildContext context, ThemeProvider provider) {
    return Row(
      mainAxisAlignment: MainAxisAlignment.spaceBetween,
      children: [
        Text(
          "跟随系统",
          style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500),
        ),
        Transform.scale(
          scale: 0.5,
          child: Switch(
            value: provider.themeMode == ThemeMode.system,
            onChanged: (value) {
              if (value) {
                provider.switchTheme("system");
              } else {
                provider.switchTheme("light");
              }
            },
            activeColor: Colors.orange,
          ),
        ),
      ],
    );
  }

  static Widget _buildOptionTile({
    required String title,
    required bool isSelect,
    required bool isSystem,
  }) {
    return Row(
      mainAxisAlignment: MainAxisAlignment.spaceBetween,
      children: [
        Text(
          title,
          style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500),
        ),
        if (isSystem) Icon(Icons.block, size: 14),
        if (isSelect) Icon(Icons.check, size: 14),
      ],
    );
  }
}
  • 全局轻提示
dart 复制代码
class AppPromptOverlay {
  static OverlayEntry? _overlayEntry;

  static void show({
    required BuildContext context,
    required String message,
    bool showIcon = true,
    String position = 'center',
    int durationMilliseconds = 1500,
  }) {
    if (_overlayEntry != null) return;

    final overlayState = Overlay.of(context);
    final theme = Theme.of(context).extension<AppColors>()!;

    final promptWidget = Material(
      color: Colors.transparent,
      child: Container(
        padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
        decoration: BoxDecoration(
          borderRadius: BorderRadius.circular(12.0),
          color: theme.contrastPure,
          boxShadow: [
            BoxShadow(
              color: theme.contrastPure!.withOpacity(0.2), // 阴影颜色和透明度
              offset: Offset(0, 4), // 向下偏移4个像素
              blurRadius: 8.0, // 模糊半径为8个像素
              spreadRadius: 0, // 不改变阴影大小
            ),
          ],
        ),
        child: Row(
          mainAxisSize: MainAxisSize.min,
          children: [
            if (showIcon)
              ClipRRect(
                borderRadius: BorderRadius.circular(24.0),
                child: Image.asset("${AppEnumAssets.basePath}icon.png",
                    width: 24, height: 24),
              ),
            if (showIcon) SizedBox(width: 8),
            Text(
              message,
              style: TextStyle(
                  fontSize: 12,
                  fontWeight: FontWeight.w500,
                  color: theme.subjectPure),
            ),
          ],
        ),
      ),
    );

    // 动画起始和结束偏移量,根据位置动态设置
    Offset beginOffset;
    Offset endOffset = Offset.zero;

    switch (position.toLowerCase()) {
      case 'top':
        beginOffset = Offset(0, -1.0); // 从顶部进场
        break;
      case 'bottom':
        beginOffset = Offset(0, 1.0); // 从底部进场
        break;
      default: // 默认居中,
        beginOffset = Offset(1, 0);
    }


    // 使用 AppAnimatedOverlay 包裹
    _overlayEntry = OverlayEntry(
      builder: (BuildContext context) {
        return AppAnimatedOverlay(
          content: promptWidget,
          beginOffset: beginOffset,
          endOffset: endOffset,
          position: position,
          showOverlay: false,
          duration: const Duration(milliseconds: 200), // 动画时间
          onClose: hide, // 动画结束后关闭
          dismissOnBackgroundTap: true, // 点击蒙层不关闭
        );
      },
    );

    // 显示 OverlayEntry
    overlayState.insert(_overlayEntry!);

    // 自动关闭
    Future.delayed(Duration(milliseconds: durationMilliseconds), () {
      AppAnimatedOverlay.close();
    });
  }

  /// 隐藏提示框  将hide方法 绑定给AppAnimatedOverlay的onClose回调   onClose 会在动画执行完成后执行
  static void hide() {
    _overlayEntry?.remove();
    _overlayEntry = null;
  }
}

1.5 应用反馈(loading、弹框等)

优化方案
  • 可分为flutter原生和通过Getx衍生出的弹框,现主要讨论flutter原生。
  • loading分为圆形、和进度条形有不同的使用场景。进度条用OverlayEntry搭建(有蒙层,展示时不允许点击其他区域),用于初进应用、初进某个页面时进行展示,短时间内不会再出现;圆形的LoadingCupertinoActivityIndicator搭建(也可以写Lottie动画),主要是Widget组件,是页面展示的一部分,主要用在加载列表、刷新页面时,可以点击其他任一区域。
  • ConFirm弹框也进行封装,Title Content ConfirmText CancalText等都是可配置,按钮的点击回调,弹框的整体样式都可定制。
  • Toast轻提示也进行统一封装,是否展示Icon Message position duration都对外进行暴露
  • 各类按钮和按钮的点击、双击、长按、按下的反馈都封装到RippleButton,统一对应用所有的可点击Widget进行包裹,并对外暴露点击、双击、长按、按下事件
代码演示
dart 复制代码
class AppRippleButton extends StatelessWidget {
  final Widget child;
  final VoidCallback? onTap;
  final ValueChanged<TapDownDetails>? onTapDown;
  final Color backgroundColor;
  final Color splashColor;
  final Color highlightColor;
  final BorderRadius? borderRadius;
  final BorderRadius? rippleRadius;
  final EdgeInsets? padding;
  final VoidCallback? onLongPress;

  const AppRippleButton({
    required this.child,
    this.onTap,
    this.onTapDown,
    this.backgroundColor = Colors.transparent,
    this.splashColor = Colors.black12,
    this.highlightColor = Colors.black26,
    this.borderRadius = defaultBorderRadius,
    this.rippleRadius,
    this.padding,
    this.onLongPress,
    super.key,
  });

  @override
  Widget build(BuildContext context) {
    final isDisabled = onTap == null;

    return Material(
      color: isDisabled ? Colors.grey.shade300 : backgroundColor,
      borderRadius: borderRadius,
      child: InkWell(
        onTap: isDisabled ? null : onTap,
        onTapDown: isDisabled ? null : onTapDown,
        onLongPress: isDisabled ? null : onLongPress,
        splashColor: isDisabled ? Colors.transparent : splashColor,
        highlightColor: isDisabled ? Colors.transparent : highlightColor,
        borderRadius: rippleRadius ?? borderRadius,
        child: ConstrainedBox(
          constraints: const BoxConstraints(minWidth: 0.0, minHeight: 0.0),
          child: padding != null ? 
          Padding(
            padding: padding ?? EdgeInsets.zero,
            child: Opacity(
              opacity: isDisabled ? 0.5 : 1.0,
              child: child,
            ),
          ): 
          Opacity(
            opacity: isDisabled ? 0.5 : 1.0,
            child: child,
          )
        ),
      ),
    );
  }
}
  • 每一处按下都有水波纹

1.6应用刷新机制

  • 应用中使用的场景是:token已经过期,用户仍请求了某些请求后,服务端返回状态码为401,该接口的数据未正常返回。
  • 处理方式是将这些请求记录下来,重新登录后,再次请求。
  • 还有一种方式是,通过providergetx做一个刷新的标记,将其注入到build函数中,将这个标记的值变为true时,重新请求一次接口,注意一定要把接口单独写成一个函数。

1.7 WebView

  • 加载本地html,使用evaluateJavascript渲染接口返回的js代码,同时通过 InAppWebView 提供的回调监听 JS 执行结果
  • 加载远程url
  • 混合开发WebViewFlutter应用通信,可以使用 WebView 提供的 JS Bridge(如 JavascriptChannel)实现通信。
dart 复制代码
// JS 发送消息给 Flutter
window.flutter_inappwebview.callHandler('methodName', arg1, arg2);

// Flutter 调用 JS
controller.evaluateJavascript(source: "yourFunction()");

2 网络请求

2.1封装统一的Http

  • 请求拦截器、响应拦截器
  • 特殊的状态码(401)、特殊的网络请求(无须校验token)...,都统一在Http类中做出封装,避免在业务代码中写
  • 对于请求量大的接口(上传接口或下载大文件)时,如果后端支持Content-Length 那么在客户端的进度条展示,如果服务端没有该字段,客户端可以用Steam自行模拟。

2.2 对报错进行统一处理

  • 遇到和服务端约定好的状态码(401:token过期),在客户端展示的逻辑抽离到一个公共方法类中。
  • 如果是用户登录凭证过期,那么重新登录后,把之前401的请求记录下来,用户重新登录后,立刻重新调用401的请求。

2.3 多接口并发请求

  • 使用 Future.wait 同时触发多个接口请求,也可以监听每个请求的状态
  • 在并发请求中,使用错误捕获组(如 try-catchrunZonedGuarded)防止接口失败导致整体崩溃。

3. 数据模型

3.1 数据响应式

  • Flutter也是单向数据流,不像RN Uniapp ,所以有些重新需要手动去实现一个数据响应式;场景是父子组件如何数据同步、祖先组件和后代组件的数据同步。比较难实现的是复杂的数据类型,如通过RxMap维护的长列表,里面的List中的某个对象里的views字段在子组件修改了,父组件没有更新。
  • 实现的步骤需要在父组件和子组件的controller中分别都暴露出一个updateViews的方法,需要父子组件的controller中有一个独特的唯一标识如typeId,进行父子组件内修改views后同步到另一方。

3.2 跨组件事件通信

  • 两个组件、页面之间没有任何关系,完全是独立的,但是需要在一方修改了状态后,另一方需要更新或触发某方法。
  • 目前用到的是getx,独特的标记controller,通过修改该标记的值,触发某方法。
  • ... 可能也有事件总线的机制

3.3 本地存储

  • 将一些数据量大、更新频率不会非常高的组件如PageView依赖的数据源、搜索历史、动态添加的tabbar、用户信息、用户的独特配置(主题、语言)等用GetxStoage进行缓存,从而提高用户的体验。
  • GetxStoage只能存储键值对,大小应当是kb,所以需要对存储的数据进行json化,如果是可修改、可删除、可添加等,还需要对RxList进行转义处理。
  • 所有存储的列表类数据都做了条数限制(例如搜索历史累计20条后,会覆盖最先添加的数据)
  • sqlLite暂时未用到
代码演示
dart 复制代码
class SearchHistoryController extends GetxController {
  final GetStorage _storage = GetStorage("search_history_box"); // 指定命名空间
  final String _key = "search_history_box";
  final int _maxRecords = 12;

  // 响应式的搜索历史记录
  RxList<String> searchHistory = <String>[].obs;

  @override
  void onInit() {
    super.onInit();
    _loadSearchHistory(); // 加载本地存储的数据
  }

  // **加载本地存储的搜索记录**
  void _loadSearchHistory() {
    // 从存储中读取数据
    final storedHistory = _storage.read<List<dynamic>>(_key);

    // 如果有数据,将其转换为响应式的 RxList
    if (storedHistory != null) {
      searchHistory.addAll(storedHistory.whereType<String>()); // 确保是 String 类型
    }
  }

  // **添加新的搜索关键词**
  void addSearchKeyword(String keyword) {
    if (keyword.isEmpty || searchHistory.contains(keyword)) {
      return; // 避免空值或重复值
    }

    // 超过最大记录限制,移除最早的记录
    if (searchHistory.length >= _maxRecords) {
      searchHistory.removeAt(0);
    }

    // 添加新关键词到列表
    searchHistory.add(keyword);
    Logger.info("$searchHistory" , tag: "addSearchKeyword");
    // 将更新后的列表保存到本地存储
    _storage.write(_key, searchHistory.toList()); // 转为普通列表保存
  }

  // **清空所有历史记录**
  void clearSearchHistory() {
    searchHistory.clear();
    _storage.remove(_key); // 移除存储中的数据
    update();
  }

  void removeSearchKeyword(String keyword){
    if(keyword.isEmpty){
      return;
    }
    searchHistory.remove(keyword);
    _storage.write(_key, searchHistory.toList());
  }
}

4. 性能监测

  • 对应用启动的各项指标进行标记,计算每个阶段的耗时(ms),以便开发时调试、优化。断点的阶段有入口文件------加载config------配置主题、语言------渲染第一帧等
  • 如进入入口文件到flutter 框架初始化 。mainStart to Flutter-Initialized: 133ms
  • 还可以对每个页面、每个时刻的gpu等进行监测

代码

  • main.dart 在入口文件进行监测
dart 复制代码
void main() async {
  final startupMonitor = StartupMonitor();
  startupMonitor.mark("mainStart");
  WidgetsFlutterBinding.ensureInitialized(); // Flutter 框架初始化
  startupMonitor.mark("Flutter-Initialized");

  // 初始化配置
  final appConfig = await appInit();
  startupMonitor.mark("appConfig-Initialized");
  // 创建 ThemeProvider 实例
  final themeProvider = ThemeProvider();
  // 创建 AppRefreshProvider 实例
  final appRefreshProvider = AppRefreshProvider();
  await themeProvider.initializeTheme(); // 创建实例时 就要调用init 初始化 
  startupMonitor.mark("themeProvider-Initialized");
  runApp(
    MultiProvider(
      providers: [
        ChangeNotifierProvider(create: (_) => themeProvider), // 注册 ThemeProvider
        ChangeNotifierProvider(create: (_) => appRefreshProvider)  // AppRefreshProvider
      ],
      child: MyApp(config: appConfig),
    ),
  );
  WidgetsBinding.instance.addPostFrameCallback((_) {
    startupMonitor.mark("firstFrameRendered");
    startupMonitor.logResults();
  });
}

5. 日志上报

  • 封装统一的日志工具Logger,提供四个等级的日志,在输出时添加Tag 当前时间 日志等级 以及对四个等级进行颜色区分 。(Info warn error debug
  • 其中Error等级日志添加stackTrace ,提供上下文和详细调用路径。
  • 开发环境不做多余处理,生成环境时,过滤掉所有的日志,除Error等级日志,全部过滤,并将Error等级日志全部强制上传到服务端。
  • 一般的埋点调试都是对用户行为分析,如支付页面停留时间、哪些页面浏览时间长、哪些关键页面打开多少次,这些统计在客户端进行GetxStoage进行缓存,累计到一定条数(20)再进行上报服务端,同时这个方法也提供强制上报的逻辑,用来排查线上用户遇到问题,即使处理。

代码

dart 复制代码
enum LogLevel { info, warn, error, debug }
class Logger {

  static AppConfig? _config;

  // 初始化 Logger 配置
  static void init(AppConfig config) {
    _config = config;
  }

  static String _formatTimestamp(DateTime timestamp) {
    return DateFormat('yyyy-MM-dd HH:mm:ss').format(timestamp);
  }

  static const Map<LogLevel, String> _levelColors = {
    /* 
    32 Green
    33 Yellow
    31 Red
    34 Blue
    36 青色
     */
    LogLevel.info: '\x1B[33m',  // 青色
    LogLevel.warn: '\x1B[35m',  // 紫色
    LogLevel.error: '\x1B[31m', // Red
    LogLevel.debug: '\x1B[34m', // Blue
  };

  static const String _resetColor = '\x1B[0m';

  static dynamic _tryDecodeJson(dynamic message) {
    if (message is String) {
      try {
        final decoded = json.decode(message);
        return const JsonEncoder.withIndent('  ').convert(decoded);
      } catch (e) {
        return message;
      }
    }
    return message;
  }

  /// 日志输出方法
  static void log(
    dynamic message, {
    LogLevel level = LogLevel.info, // 默认日志等级
    String? tag, // 日志标签
    dynamic error, // 异常对象
    StackTrace? stackTrace, // 堆栈信息
  }) {
    // 如果是 release 模式,直接返回
    if (kReleaseMode || _config?.environment == "release" ) return;

    final timestamp = _formatTimestamp(DateTime.now());
    final logLevel = level.toString().split('.').last.toUpperCase();
    final tagString = tag != null ? "[$tag]" : "";
    final errorString = error != null ? "\nError: $error" : "";
    final stackTraceString = stackTrace != null ? "\nStackTrace: $stackTrace" : "";

    final decodedMessage = _tryDecodeJson(message);
    final color = _levelColors[level] ?? '';
    final logMessage = "$color $timestamp $logLevel $tagString: $_resetColor $decodedMessage$errorString$stackTraceString ";

    
    // ignore: avoid_print
    print(logMessage);
  }

  /// 简单快捷的日志方法
  static void info(String message, {String? tag}) {
    log(message, level: LogLevel.info, tag: tag);
  }

  static void warn(String message, {String? tag}) {
    log(message, level: LogLevel.warn, tag: tag);
  }


  /* 
  message:始终是主描述,说明发生了什么。
  tag:有助于分类日志,特别是复杂项目中。
  error:捕获的异常,方便查看问题的原因。
  stackTrace:提供上下文和详细调用路径,通常与 error 搭配使用。
  */
  static void error(String message, {String? tag, dynamic error, StackTrace? stackTrace}) {
    log(message, level: LogLevel.error, tag: tag, error: error, stackTrace: stackTrace);
  }

  static void debug(String message, {String? tag}) {
    log(message, level: LogLevel.debug, tag: tag);
  }
}
相关推荐
ljx140005255012 分钟前
Android AudioFlinger(一)——初识AndroidAudio Flinger
android
ljx140005255014 分钟前
Android AudioFlinger(四)—— 揭开PlaybackThread面纱
android
Codingwiz_Joy15 分钟前
Day04 模拟原生开发app过程 Androidstudio+逍遥模拟器
android·安全·web安全·安全性测试
叶羽西17 分钟前
Android15 Camera框架中的StatusTracker
android·camera框架
梦中千秋21 分钟前
安卓设备root检测与隐藏手段
android
buleideli1 小时前
CameraX学习2-关于录像、慢动作录像
android·camerax
LinXunFeng2 小时前
Flutter - iOS编译加速
flutter·xcode·apple
stevenzqzq3 小时前
android paging使用教程
android
无敌发光大蟒蛇3 小时前
MySQL第一次作业
android·数据库·mysql
m0_748238924 小时前
MySQL Workbench菜单汉化为中文
android·数据库·mysql