作为Android开发,刚开始接触Flutter的时候,最大的感受就是“处处都眼熟,处处又都不太一样”。尤其是从事件分发、列表 RecyclerView 到 TextView 这一套 Android 核心认知迁移过来,如果不把底层逻辑的差异理清楚,写出来的 Flutter 代码就会透着一股“Android 味”,能用,但不够地道。这篇笔记是第三节,我打算站在一个纯 Android 开发者的视角,把 Flutter 的触摸事件、List 列表和 Text 文本这三个最基础也最常用的组件彻底过一遍,对比着看,把那些文档里没说透的“为什么”掰开揉碎讲清楚。
这节内容适合两类人:一种是正在从 Android 转向 Flutter 的客户端开发,想快速建立对标认知;另一种是已经写了一段时间 Flutter,但总觉得对事件处理、列表复用和文本样式控制不够透彻的朋友。文中不会有那种通篇 Copy 官方文档的废话,全部是对照着 Android 原生机制逐条拆解的实操总结和我踩过的坑。
1. 内容整体设计与思路拆解
1.1 为什么从 Android 视角切入 Flutter 学习
很多 Flutter 教程默认读者是从零开始接触客户端开发,所以它们会花大量篇幅讲解 Widget、Element、RenderObject 这些概念。但 Android 开发者的学习路径完全不同。我们脑子里已经有一套成熟的 UI 构建和事件处理体系,比如 View 树的测量布局绘制流程、事件分发拦截机制、RecyclerView 的 ViewHolder 复用机制、TextView 的 SpannableString 富文本体系。这些知识体系是巨大的财富,但同时也是学习 Flutter 的最大障碍,因为我们总不自觉地拿 Android 的那套模型去套 Flutter,发现套不上就开始懵。
我在学习时的策略很简单:找对应关系,但不找等价关系。比如 Android 的 View 对应 Flutter 的 Widget,但 Widget 是不可变的配置描述,View 是可变的持有状态的对象;Android 的 ViewGroup 负责事件分发拦截,Flutter 里则是 HitTest 命中测试加手势竞技场;Android 的 RecyclerView.Adapter 负责创建 ViewHolder 和绑定数据,Flutter 的 ListView.builder 直接用 itemBuilder 函数一把梭,连 ViewHolder 都省了。这些对应关系搞清楚后,思维转换会非常顺滑。
这一节我选择了触摸事件、List、Text 这三个点作为切入点,其实是有讲究的。它们分别对应 Android 开发中事件分发、列表复用、文本渲染三大高频场景,把这三大块搞明白,日常 Flutter 开发的 80% 场景基本都能顺利覆盖,也能顺带理解 Flutter “一切皆 Widget”的核心哲学在实际组件层面是怎么落地的。
1.2 从 View 体系到 Widget 体系的核心思维转变
Android 的 View 体系是命令式的。你在 XML 里定义一个布局,在 Activity 里通过 findViewById 拿到这个 View 对象,然后可以随时调用 setText、setBackgroundColor、setVisibility 来改变它的状态,UI 会立刻响应。这是典型的事后修补逻辑,View 对象一直活着,你想怎么改就怎么改。
Flutter 的 Widget 体系是声明式的。Widget 是一个不可变的配置文件,每一次 UI 发生变化,Flutter 都会重新执行 build 方法生成一棵新的 Widget 树,然后通过 Diff 算法对比新旧差异,只更新发生变化的部分。这不像是修改一张图片,更像是用新图片整体替换,但底层只刷新像素变化的部分。
这个思维转变带来的直接后果是:在 Android 里会习惯性写的“先拿到 View 引用,稍后在某个回调里修改 UI”的代码模式,在 Flutter 里会被替换成“改状态,触发重建,UI 自动跟随变化”的模式。基于这样的底层差异,再去看触摸事件、List 复用和 Text 样式更新时,很多设计选择就都能解释通了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 触摸事件:从事件分发到手势竞技场
2.1 Android 的事件分发机制与 Flutter 的命中测试对比
Android 的触摸事件体系经过多年演进,核心流程是 Activity → ViewGroup → View 逐层分发。事件从 dispatchTouchEvent 进入,经过 onInterceptTouchEvent 决定是否拦截,最终由 onTouchEvent 处理。这套机制的特点是可以由父视图决定要不要把事件交给子视图,拦截逻辑是自上而下的控制流,非常灵活,但也很容易让开发者写出“明明是子 View 的点击,却被父 View 抢走”的诡异问题。
Flutter 走的是另一条路。触摸事件的起点是 RenderObject 的 hitTest 方法,它会从叶子节点向根节点反向遍历,把命中路径上的所有对象记录到一个 HitTestResult 列表里。事件派发时,会依次调用路径上每个对象的 handleEvent 方法。这个流程和 Android 的方向是反着的,不是在分发前先问要不要拦截,而是先收集所有可能接收事件的候选对象,然后再统一处理。
初次接触这个机制的时候,我特别不适应。Android 的思维是“事件来了,我在路上拦一下”,Flutter 的思维是“先把所有想接收事件的人登记下来,再挨个问谁要处理”。从纯技术角度看,Flutter 的设计其实更优雅,因为避免了 Android 那种层层分发的高开销,也让手势识别变得高度组件化,任意一个 Widget 都可以登记接收事件,完全不用管它身处多深的嵌套层级中。
2.2 GestureDetector 与 Listener 的正确选型
Flutter 提供了两层触摸事件 API,这又是 Android 开发者最容易搞混的点。顶层是 GestureDetector,底层是 Listener。两者的关系类似于 Android 的 GestureDetector 和 View.onTouchEvent 的关系,但又不完全一致。
Listener 是 Raw 层面的监听器,对应 PointerDownEvent、PointerMoveEvent、PointerUpEvent 这些原始指针事件,它不会帮你做任何手势判断,只负责把原始的触摸坐标和状态抛给你。GestureDetector 则是在 Listener 之上的一层封装,它内部用 GestureRecognizer 识别手势,支持 onTap、onDoubleTap、onLongPress、onPan、onScale 等高级语义。
举个实际例子:Android 里用 GestureDetector.SimpleOnGestureListener 判断双击,需要处理 onDown、onSingleTapUp、onDoubleTap 等多个回调的时序;Flutter 里只需要给 GestureDetector 的 onDoubleTap 赋值就行了,手势竞技场会把单双击的争抢逻辑处理得干干净净。
我的经验是:能用 GestureDetector 解决的场景就不要碰 Listener。Listener 适合画板、自定义绘制、手势轨迹记录这类需要原始数据的场景,而普通点击、拖动、缩放这些语义操作应该全部交给 GestureDetector。
对于需要同时支持拖拽和点击的场景,比如列表项侧滑删除,Android 里要处理滑动与点击的互斥逻辑,而在 Flutter 里只需要同时给 GestureDetector 设置 onPanUpdate 和 onTap,GestureArena 会自动根据手势的移动距离和方向判断是 tap 还是 pan,这在 Android 里光是调滑动阈值就要折腾半天。
2.3 手势竞技场的工作机制
手势竞技场(Gesture Arena)是 Flutter 触摸体系里最核心也最特别的概念,Android 里完全没有对应的东西。它的工作流程是这样的:当一个指针事件发生时,所有被命中的手势识别器都会登记为该手势的竞争者。随后,当事件流继续产生时,每个竞争者会根据自己的识别规则尝试宣称“这是我的手势”。比如 TapGestureRecognizer 在指针抬起且位移未超过阈值时宣称胜利,而 DragGestureRecognizer 在指针移动超过 touchSlop 时宣称胜利。
如果多个竞争者同时宣称胜利,竞技场会有一个裁决机制。比如双击手势确实识别出来时,单击手势会先认输,把已经触发过的 onTap 取消掉,等待第二次点击完成后触发 onDoubleTap。这种“先上膛,再确认”的机制,让手势组合可以非常自然地嵌套,不需要开发者手动处理互斥。
我在模拟一个 iOS 风格的 cell 侧滑菜单时,就深刻体会到了竞技场的威力。当时需要支持手指滑动展开菜单、点按菜单项触发操作、上下滚动列表三个手势同时工作。在 Android 里,水平滑动和垂直滑动在同一个 ScrollView 里要写大量的 touch slop 判断和事件拦截代码。Flutter 里只需要在 GestureDetector 上声明 onHorizontalDragUpdate,同时保证 ListView 的 scrollDirection 是垂直的,竞技场会自动根据初始移动方向决定谁胜出,代码量少得令人发指。
2.4 触摸事件透传与手势冲突的处理心得
尽管竞技场能处理绝大部分冲突,但有些场景还是需要手动干预。比如一个卡片上既有 onTap 又有 onLongPress,同时又嵌套在水平滚动的 PageView 里。竞技场的默认裁决偶尔会让你觉得“这次手势怎么走了另一个方向”,这时候就需要调 GestureDetector 的 behavior 参数。
behavior 有三个取值:HitTestBehavior.deferToChild(默认,只在子组件命中时才接收事件)、HitTestBehavior.opaque(即使背景透明也接收事件,但不影响命中路径上更深层组件)、HitTestBehavior.translucent(接收事件,但整个命中路径都参与)。Android 里的 clickable 属性会让 View 变成可点击,同时事件不会穿透,类比过来大概是 deferToChild 和 opaque 的中间态。
另外,Widget 树中存在 IgnorePointer 和 AbsorbPointer 两个特殊的组件。IgnorePointer 可以让整个子树对所有指针事件“隐身”,适合做加载状态的遮罩层;AbsorbPointer 会接收事件但不传递给子树,相当于消费掉所有触摸,适合做弹窗底部的模态屏蔽。这两个在 Android 里要配合 setOnTouchListener 和 onInterceptTouchEvent 才能实现,Flutter 直接一个 Tag 搞定,非常清爽。
3. List 列表:从 RecyclerView 到 ListView
3.1 ListView 的三种构建方式与 Android 的对应关系
列表是客户端应用里最核心的组件,Android 里从 ListView 时代的 BaseAdapter,到 RecyclerView 时代的 RecyclerView.Adapter + ViewHolder + LayoutManager,整个体系已经非常成熟。Flutter 的 ListView 则简单粗暴地提供了三种构造方式,各自适用于不同场景。
第一种是 ListView(children: [...]),直接把所有子 Widget 一次性构造出来,适合列表项少、结构固定的场景。第二种是 ListView.builder,对应 Android 里 RecyclerView 的核心模式——懒加载,只有在项滚动到可视区域附近时才构建,性能最优,适合长列表。第三种是 ListView.separated,在 builder 的基础上增加了分隔线,效果类似于给 RecyclerView 的 ItemDecoration 加 Divider,但用起来更简单直接。
初次从 RecyclerView 切到 Flutter 时,我总是下意识地在 ListView 里手动判断 index 来决定返回什么类型,后来才意识到 Flutter 用 itemBuilder 天然就支持多类型 Item。只需要在 builder 函数内部根据 index 返回不同的 Widget 就行,不需要像 Android 那样 override getItemViewType 再处理 viewType 对应的 ViewHolder 创建逻辑。
code复制ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
if (items[index].type == 0) {
return _HeaderItem(item: items[index]);
} else {
return _ContentItem(item: items[index]);
}
},
)
这段代码里的 itemBuilder 就是 Android 中 onBindViewHolder 的替代品。但有个细节需要注意,Android 的 onBindViewHolder 会反复被调用,而 Flutter 的 itemBuilder 的调用频率由父级 Widget 的更新机制决定。子 Widget 内部的状态(比如 TextField 的输入值)不会因为父级 rebuild 而自动保存,这点和 RecyclerView 里 ViewHolder 持有子 View 状态的性质完全不同,后面踩坑部分我会详细说。
3.2 itemExtent 与性能优化的底层逻辑
Android 开发都知道,RecyclerView 的性能核心在于 ViewHolder 复用和 LayoutManager 的缓存机制。Flutter 没有 ViewHolder 概念,但它有一个绝对不容忽视的性能参数——itemExtent。
itemExtent 的作用是提前告诉 ListView 所有 item 的高度是固定的。这一点在 Android 里对应 RecyclerView 的 setHasFixedSize(true),但比它更彻底。RecyclerView 的 hasFixedSize 只是优化了测量流程,ListView 的 itemExtent 则直接让滚动条的拉伸比例计算和 item 的预构建范围判断变得极其高效,因为不需要提前 measure 就能计算总高度。
如果列表项的尺寸不固定,Flutter 的 ListView.builder 在滚动时,需要实时估算当前可视区域需要什么 item,这会导致滚动时频繁构建和销毁,在低端机上能明显感觉到卡顿。所以我给团队定了一条规范:凡是列表项高度固定或近似固定的场景,必须设置 itemExtent;如果高度确实不固定,优先用 prototypeItem(给定一个代表项作为高度基准)代替 itemExtent,能省多少性能是多少。
code复制ListView.builder(
itemExtent: 72,
itemCount: 1000,
itemBuilder: (context, index) => _ListItem(index: index),
)
3.3 无限列表、数据刷新与状态保持的实战技巧
做即时通讯或者信息流时,会遇到无限滚动列表的需求。Flutter 里实现“加载更多”非常简单,给 ListView 加一个 ScrollController,监听滚动位置,当 position.pixels 接近 position.maxScrollExtent 时触发下一页加载即可。这和 Android 里给 RecyclerView 添加 OnScrollListener 判断 lastVisibleItemPosition 是同一个逻辑,但代码要直观得多。
code复制scrollController.addListener(() {
if (scrollController.position.pixels >=
scrollController.position.maxScrollExtent - 200) {
_loadMore();
}
});
另一个高频场景是下拉刷新。Android 里用 SwipeRefreshLayout 包一层 RecyclerView,Flutter 里则用 RefreshIndicator 包住 ListView,设置 onRefresh 回调。这里有几个细节值得注意:onRefresh 里必须返回一个 Future,等数据加载完成后会自动收起刷新动画;如果 onRefresh 返回 null,刷新指示器就会一直挂着,看起来像卡死了。
列表的数据刷新有个让我印象极深的坑:在 Android 里,我习惯更新数据后调用 adapter.notifyDataSetChanged(),让 RecyclerView 整体重绘一遍。在 Flutter 里,如果你在 State 里 setState 后重新给 ListView.builder 传入新的数据源,列表确实会更新,但如果这个列表是嵌套在 TabBarView 或 PageView 里的,父页面 rebuild 时子列表的状态很容易被重置,表现为滚动位置被拉回顶部、展开的 item 收起等诡异现象。
后来我总结出的规律是:列表数据更新时,要尽量在 item 的 Key 上做文章。给每个 item 指定一个稳定的 ValueKey,比如 ValueKey(item.id),这样 Flutter 的 Diff 算法就能精确识别哪些 item 是新增、删除还是移动,而不是整棵树重建。这个写法相当于 Android RecyclerView 的 DiffUtil,但用起来成本更低。
3.4 使用 CustomScrollView 与 Sliver 实现复杂滚动布局
当页面需要同时容纳头部轮播图、Tab 导航、横向列表和纵向信息流时,Android 的标准做法是 CoordinatorLayout + AppBarLayout + RecyclerView 的嵌套滚动体系。这套组合拳在项目复杂度上来以后,经常出现滚动联动失灵、惯性滚动冲突的问题,排查成本极高。
Flutter 里的标准答案是 CustomScrollView 加各种 Sliver 家族组件。Sliver 是 Flutter 对“可滚动区域的逻辑切片”的抽象,和 Android 的 NestedScrollingChild 有些神似,但设计更纯粹。你可以把 CustomScrollView 理解成一个大容器,里面的每个 Sliver 是独立的一段滚动内容,它们共享同一个可滚动区域和同一条滚动轴线。
常见用法是:SliverAppBar 做折叠头部,SliverGrid 做网格区域,SliverList 做列表区域,SliverToBoxAdapter 包裹普通 Widget 做不规则块。切换 Tab 时,只需要在 TabBarView 里放多个 CustomScrollView,每个 CustomScrollView 保持自己的滚动位置,体验和 Android 的 Behavior 联动效果几乎一样,但不用写任何手势拦截代码。
code复制CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 200,
pinned: true,
flexibleSpace: FlexibleSpaceBar(title: Text('标题')),
),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => _ListItem(index: index),
childCount: 100,
),
),
],
)
我在迁移一个复杂电商首页时,从 CoordinatorLayout 嵌套 NestedScrollView 换成 CustomScrollView,代码量大概缩减了 40%,很多原先要手动协调的滚动问题直接消失。这算是我强烈推荐 Flutter 的理由之一。
4. Text 文本:从 TextView 到 Widget 的渲染差异
4.1 Text 组件的基本用法与 Android TextView 对标
TextView 是 Android 里最基础的文本组件,它有 text、textSize、textColor、maxLines、ellipsize 这些属性,还支持 SpannableString 做富文本。Flutter 的 Text 组件把这些能力全部整合到两个地方:一个是 Text 本身的构造参数,比如 data、style、textAlign、maxLines、overflow;另一个是 TextStyle 类,管理字体大小、颜色、字重、行高、字间距等排版属性。
从代码直观对比,Android 的:
code复制textView.setText("Hello");
textView.setTextSize(16);
textView.setTextColor(Color.RED);
textView.setMaxLines(2);
textView.setEllipsize(TextUtils.TruncateAt.END);
在 Flutter 里变成:
code复制Text(
'Hello',
style: TextStyle(
fontSize: 16,
color: Colors.red,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
)
区别不仅仅是语法,而是对象性质的变化。Android 的 TextView 是一个可变对象,你可以在任意时刻 setText,UI 立即刷新。Flutter 的 Text 是不可变的 Widget,每次数据变化时,都是在 build 方法里传入新的字符串。在 Flutter 里,你的代码不是在“修改一个文本控件”,而是在“描述文本应该是什么样”,这个认知转变对写过很多 Android UI 代码的人来说,是第一个需要克服的思维惯性。
4.2 文本样式与字体渲染的差异处理
Android 的 fontFamily 属性用的是系统字体名,比如 sans-serif、serif、monospace,第三方字体需要放进 res/font 目录,或者下载 Typeface 手动设置。Flutter 的方式是把字体文件放进 assets 目录,然后在 pubspec.yaml 里声明,指定 family 名,之后在 TextStyle 里直接用这个 family 名引用。
code复制flutter:
fonts:
- family: MyFont
fonts:
- asset: assets/fonts/MyFont-Regular.ttf
- asset: assets/fonts/MyFont-Bold.ttf
weight: 700
字体声明这里有一个特别容易踩到的坑:中文字体和英文字体的 fallback 规则。Android 的 TextView 遇到系统字体没有覆盖的字符时,会自动 fallback 到系统默认字体;Flutter 里如果指定了 fontFamily,而该字体文件不包含某些字符(比如生僻汉字或者特殊符号),是不会自动 fallback 到系统中文字体的,表现就是字体显示出豆腐块或者奇怪的替代字形。解决办法是自定义 FontLoader 或者在 TextStyle 里用 fontFamilyFallback 指定一组备用字体。
TextStyle 里还有一个 Android 开发者很容易忽略的参数:height。Android 里控制行高是通过 lineSpacingExtra 和 lineSpacingMultiplier 两个属性实现的,而 Flutter 的 height 是行高与字体大小的比例。比如 fontSize: 20,height: 1.5,实际行高就是 30 像素。这个参数对设计和 UI 还原精度影响极大,推荐 UI 走查时优先检查它。
字体渲染的另一个差异是 textScaleFactor。Android 系统允许用户调整系统字体大小,Text 会随之缩放;Flutter 里 textScaleFactor 控制同样的行为。在某些需要强制保持特定字体大小的场景(比如 UI 上的角标数字),可以用 MediaQuery 覆盖掉系统的 textScaleFactor,但这样做会影响可访问性,使用时需要权衡。
4.3 富文本与混合样式的实现方式
Android 的富文本是 SpannableString 配各种 Span,比如 ForegroundColorSpan、StyleSpan、ClickableSpan、ReplacementSpan 等。Flutter 里对应的是 TextSpan 树,一棵支持嵌套的文本节点树。TextSpan 可以嵌套多个子 span,每个子 span 可以有自己的 style 和 recognizer,实现了类似 ClickableSpan 的点击效果。
code复制Text.rich(
TextSpan(
text: '我已阅读并同意',
children: [
TextSpan(
text: '《用户协议》',
style: TextStyle(color: Colors.blue),
recognizer: TapGestureRecognizer()..onTap = () => _openAgreement(),
),
TextSpan(
text: '和',
),
TextSpan(
text: '《隐私政策》',
style: TextStyle(color: Colors.blue),
recognizer: TapGestureRecognizer()..onTap = () => _openPrivacy(),
),
],
),
)
这段代码实现的效果是:在长文本里混入两个可点击的蓝色协议链接。这在 Android 里需要构建 SpannableString,设置 ClickableSpan,还要处理 URLSpan、鼠标中键等乱七八糟的边界情况,而 Flutter 里的 TapGestureRecognizer 天然就是为 widget 的点击设计的,处理起来干净很多。
TextSpan 还支持逐字符设置样式,比如把一段话里的数字全部标红。这个需求在 Android 里需要遍历字符然后逐个 setSpan,Flutter 可以直接在构造 TextSpan 时用正则分割文本批量生成 span 列表,也可以配合 RichText 和 TextPainter 做更底层的排版控制。
4.4 文本溢出、换行与测量问题的应对策略
Android 里文本溢出常见的处理方式是 ellipsize 加 maxLines,Flutter 里用 TextOverflow.ellipsis 是一样的效果。但在某些 UI 布局中,Text 会被放在 Row、Wrap、Flex 等容器里,这时 Text 的宽度约束来自父级,而父级又受到子级的影响,容易产生“文本把容器撑爆”的问题。
核心解法是给 Text 包一层 Flexible 或 Expanded。Row 的子级默认是尽可能大的,如果不包 Flexible,Text 就会按自己的固有宽度无限伸展,把其他组件挤出屏幕。Flexible 的作用是允许 Text 在不超过父级约束的情况下收缩,配合 overflow: ellipsis 就能实现“超长文本省略号 + 尾部图标自动靠右”的标准列表效果。这个布局问题我在 Android 里通常用 weight=1 加 ellipsize 解决,Flutter 里就靠 Flexible,两边思路异曲同工,但 Flutter 的写法更显式。
文本测量是另一个 Android 开发者容易忽略的点。Android 里测文本宽度要调 Paint.measureText,Flutter 里则可以用 TextPainter 手动测量:
code复制final textPainter = TextPainter(
text: TextSpan(text: content, style: TextStyle(fontSize: 14)),
textDirection: TextDirection.ltr,
)..layout();
double width = textPainter.width;
这在做“展开/收起”按钮时特别有用。判断文本是否超过 maxLines 才能决定要不要显示“展开”按钮,Flutter 里的 TextPainter 是唯一可靠的方法。需要注意 layout 前必须设置 textDirection,否则会抛异常。这个细节我在第一版代码里踩过坑,因为当时是从一个纯 Flutter 的项目复制过来,没有注意中文的 textDirection 设置。
5. 常见问题与排查技巧实录
5.1 触摸事件失效与手势竞争问题速查
问题一:GestureDetector 包在 ListView 的 item 里,点击无响应。 检查 ListView 的 physics 属性,如果手动设置了 ClampingScrollPhysics 或 BouncingScrollPhysics 并不会导致点击失效,真正的坑是 item 内部有横向滑动的控件,与 ListView 的垂直滚动产生了手势竞争。解决办法是给 GestureDetector 设置 dragStartBehavior: DragStartBehavior.down 或者在 ListView 上设置 cancelChildren。
问题二:外层 GestureDetector 的 onTap 和内层 GestureDetector 的 onTap 同时触发。 这种现象是因为 Flutter 的命中测试将事件发送给所有命中路径上的组件,内外两层的 GestureDetector 都登记了竞争。默认竞技场会按“最内层优先生效”的规则裁决,但如果不生效,可以显式给内层 GestureDetector 设置 behavior: HitTestBehavior.opaque,把事件锁定在内层。
问题三:PageView 嵌套水平滑动列表,横向手势经常被吞掉。 解决思路是确认为手势确定方向。水平手势与垂直手势天然可由竞技场区分,但水平与水平的手势就要看谁的位移阈值先被超过。可以尝试给内层列表设置 physics: NeverScrollableScrollPhysics(),手动在外层 PageView 的 onPageChanged 驱动内层列表滚动,不过这个方案只适合特殊交互场景。
5.2 List 更新不刷新、状态丢失与崩溃排查
列表最讨厌的问题是“数据变了 UI 没变”。Android 里通常是忘了调 notifyDataSetChanged,Flutter 里则要检查 setState 是否执行。有一个非常隐蔽的场景:你在子组件里修改了某个值,但子组件不是 StatefulWidget,它的 Text 文本没有跟着变。正确的做法是把数据提升到父组件的 State 里,或者用 ValueNotifier + ValueListenableBuilder 替代 setState,让数据的持有者只关心自己那棵树。
状态丢失的经典场景是:TextField 输入过程中,列表项因为父组件 setState 而被重建,焦点和输入内容都丢了。Android 的 ViewHolder 会保留 EditText 的实例,所以不会出现这个问题。Flutter 的解决办法是给 TextField 设置自己的 Key,比如 PageStorageKey('comment_input_$id'),让 Flutter 在重建时能找回对应的 EditableText 状态。或者把列表项抽成 StatefulWidget,让每个 item 自己持有 TextEditingController,并在 dispose 时释放。
崩溃类问题里,最常见的是“ScrollController not attached to any scroll views”,这个是因为在 ListView 还没挂载完成时就调用了 controller.addListener,或者在页面销毁后 controller 仍然被引用。解决办法是在 State.dispose 中释放 controller,并用 hasClients 判断后再调用 position 相关方法。
5.3 Text 显示异常、乱码与布局溢出问题
Text 显示乱码,先检查字符编码问题,这种情况在 Android 里非常少见,因为使用 Java 的 String 时编码统一为 UTF-16,而 Flutter 从 JSON 或后端接口读取字符串时,如果字符集解析不一致,就会出现中文乱码。排查思路是统一网络解析库的 charset 设置,或者在服务端 response header 里显式声明 UTF-8。
布局溢出是另一个高频问题。表现是页面出现黄黑相间的条纹,控制台报 RenderFlex overflowed。原因通常是 Row 或 Column 里的 Text 或 Container 超出了可用空间。解决办法是给需要收缩的组件包 Flexible,给需要固定宽度的组件设置 Expanded 的 flex 值;如果溢出是垂直方向,考虑用 SingleChildScrollView 或 ListView 替代 Column。
我还遇到过一个很匪夷所思的情况:Text 在部分 Android 机型上显示得特别大,其他机器正常。排查后发现是系统字体缩放设置的影响,Flutter 默认会跟随系统 textScaleFactor。如果业务方要求界面完全不受系统字体影响,可以在 MaterialApp 的 builder 里统一覆写 MediaQuery(textScaler: TextScaler.noScaling),但这样做要考虑无障碍需求,最好做一个开关而不是全局禁用。
5.4 性能问题定位方法与优化思路
Flutter 的性能问题定位其实比 Android 更直观。打开 Android Studio 的 Flutter Performance 工具,能直接看到每一帧的 build 耗时和 raster 耗时,还能通过 UI 左上角的性能浮层实时查看帧率。如果 build 耗时特别高,优先找列表页面的 rebuild 范围,用 const 关键字提前声明不需要变化的 Widget,可以大量削减 Diff 的耗时。
列表滑动性能优化,除了前面说的 itemExtent 和 Key 设计外,还可以用 RepaintBoundary 隔离局部刷新边界,避免因为一个 item 的动画导致整个列表区域重绘。这个组件在 Android 里没有直接对应物,但它本质上是给 RenderObject 增加一层独立的图层缓存,类似 View 的 layerType 设为 LAYER_TYPE_HARDWARE。
我踩过的一个性能坑是:在 itemBuilder 中直接创建大量的匿名 Widget 和闭包,以为没什么大碍,结果在低端机上滑动列表时帧率掉得厉害。后来用 const 关键词固定不变量、把闭包函数提取成具名方法后,肉眼可见地顺滑。Flutter 的垃圾回收对短生命周期对象很不友好,尽量减少每次 build 时产生新对象的数量才是正道。
6. 写在最后的一点个人心得
这三块内容学完之后,我最大的收获不是记住了多少 API,而是真正理解了 Flutter “everything is a widget” 这句话的分量。触摸事件不是靠 View 体系的拦截分发,而是靠手势竞技场的竞争裁决;列表不是靠 Adapter 的 ViewHolder 复用,而是靠不可变 Widget 树的 Diff 更新;文本不是可变 TextView 的实时修改,而是每次 build 的配置式声明。这套模型初看绕,但用顺了之后,代码的健壮性和可维护性比 Android 高了不少。
最后分享一个小建议:作为 Android 开发者学 Flutter,不要指望在 Flutter 里找到 View/Activity 的一一映射,而是要把 Flutter 当成一个全新的 UI 框架来学,然后用 Android 的经验去对照验证。这个学习路径我走下来是最顺的,你在迁移过程中踩过的每一个坑,都会成为你对两个框架理解加深的最好契机。
