性能优化(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
)
- 如果是分类列表(可左右滑动切换分类,采用
- 在
Getx
的Controller
中维护分类状态。- 建议不要只维护一个
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
专注于加载更多,触发时在isLoading
或isFinished
为true
时都需要return
,避免触发过于频繁,之后发起网络请求前让isLoading
为true
,请求完成后isLoading
为false
;refresh
专注于刷新,执行前先把list
请空、currentPage
改为0,isFinished
改为false,之前再发起网络请求;每次网络请求发起后,都去把对应State
的list
补充上,判断当前页是否是最后一页,如果不是让当前页++,如果是让isFinished
变成true
- 渲染时,使用
AutomaticKeepAliveClientMixin
缓存页面状态,初始化时先去加载当前的State
,然后渲染当前的State
的list
,遇到isLoading
那么展示加载中的动画,同时处理_refreshController
控制器,对加载完成、加载完毕,执行对应的方法,展示完全定制的Header
和Footer
。
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
搭建(有蒙层,展示时不允许点击其他区域),用于初进应用、初进某个页面时进行展示,短时间内不会再出现;圆形的Loading
用CupertinoActivityIndicator
搭建(也可以写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,该接口的数据未正常返回。 - 处理方式是将这些请求记录下来,重新登录后,再次请求。
- 还有一种方式是,通过
provider
或getx
做一个刷新的标记,将其注入到build
函数中,将这个标记的值变为true
时,重新请求一次接口,注意一定要把接口单独写成一个函数。
1.7 WebView
- 加载本地
html
,使用evaluateJavascript
渲染接口返回的js
代码,同时通过InAppWebView
提供的回调监听 JS 执行结果 - 加载远程
url
- 混合开发
WebView
和Flutter
应用通信,可以使用 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-catch
或runZonedGuarded
)防止接口失败导致整体崩溃。
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);
}
}