跳转至

Vue 开发者快速上手 Flutter(四)- 通过实战项目掌握核心概念

原文地址: https://88box.top 生成时间: 2026-05-19 16:32:31


Vue 开发者快速上手 Flutter(四) - hey99 知识搜索引擎

精选文章

Vue 开发者快速上手 Flutter(四)

第 4 部分 · 推荐练习任务(Vue ↔ Flutter) 学习目标(3 天):通过 3 个循序渐进的小项目,把前面学到的概念全部串起来。完成后,你已经具备阅读真实 Flutter 工程(如 gsy

更新于 2026-05-19 08:17

前端

第 4 部分 · 推荐练习任务(Vue ↔ Flutter)

学习目标(3 天):通过 3 个

循序渐进

的小项目,把前面学到的概念全部串起来。完成后,你已经具备阅读真实 Flutter 工程(如

gsy_github_app_flutter

)的能力。

前三篇我们把心智模型、Todo 练习、基础 Widget 都过完了。这一篇是"用起来"——每个任务都给 Vue 和 Flutter 两份可运行代码,

强烈建议自己先写一遍 Vue 版再对照

,把"学新框架"和"想需求"两件事分开。

一、3 天的任务清单

Day

任务

涉及知识点

难度

Day 1

计数器 + 主题切换

StatefulWidget、setState、Theme

Day 2

GitHub 用户搜索(请求 + 列表 + 三态)

http、FutureBuilder、ListView.builder

⭐⭐⭐

Day 3

个人中心(图片 + 卡片 + Tab + 跳转)

综合

⭐⭐⭐

二、任务 1:计数器 + 主题切换 ⭐

需求

显示一个数字,点

+

-

修改,

重置

归零

顶部一个

Switch

切换浅色/深色主题

计数器超过 10 显示绿色文字,低于 0 显示红色

对照 Vue:

等同于

ref(0)

+

computed

+

v-bind:class

关键学习点

StatefulWidget

setState

的最小用法

MaterialApp

theme

切换

条件样式(在 Flutter 里是 getter 返回不同的

Color

,不是切 class)

Vue 版本(

CounterPage.vue

Flutter 版本(

counter_page.dart

import

'package:flutter/material.dart'

;

class

CounterApp

extends

StatefulWidget

{

const

CounterApp({

super

.key});

@override

State createState() => _CounterAppState();

}

class

_CounterAppState

extends

State

<

CounterApp

{

bool

_dark =

false

;

@override

Widget build(BuildContext context) {

return

MaterialApp(

theme: _dark ? ThemeData.dark() : ThemeData.light(),

home: CounterPage(

dark: _dark,

onToggleTheme: (v) => setState(() => _dark = v),

),

);

}

}

class

CounterPage

extends

StatefulWidget

{

final

bool

dark;

final

ValueChanged<

bool

onToggleTheme;

const

CounterPage({

super

.key,

required

this

.dark,

required

this

.onToggleTheme});

@override

State createState() => _CounterPageState();

}

class

_CounterPageState

extends

State

<

CounterPage

{

int

_count =

0

;

Color?

get

_numberColor {

if

(_count >

10

)

return

Colors.green;

if

(_count <

0

)

return

Colors.red;

return

null

;

}

@override

Widget build(BuildContext context) {

return

Scaffold(

appBar: AppBar(

title:

const

Text(

'计数器'

),

actions: [

Row(children: [

const

Text(

'深色'

),

Switch(value: widget.dark, onChanged: widget.onToggleTheme),

]),

],

),

body: Center(

child: Column(

mainAxisAlignment: MainAxisAlignment.center,

children: [

Text(

'

$_count

'

,

style: TextStyle(fontSize:

64

, color: _numberColor),

),

const

SizedBox(height:

24

),

Row(

mainAxisAlignment: MainAxisAlignment.center,

children: [

ElevatedButton(onPressed: () => setState(() => _count--), child:

const

Text(

'-'

)),

const

SizedBox(width:

8

),

ElevatedButton(onPressed: () => setState(() => _count++), child:

const

Text(

'+'

)),

const

SizedBox(width:

8

),

OutlinedButton(onPressed: () => setState(() => _count =

0

), child:

const

Text(

'重置'

)),

],

),

],

),

),

);

}

}

void

main() => runApp(

const

CounterApp());

写完这个任务你应该想清楚的几件事

为什么主题切换的 state 要放在

CounterApp

而不是

CounterPage

因为

MaterialApp.theme

CounterApp

那一层,

state 必须在"会用到它的最近共同祖先"上

。这跟 Vue 里 prop drill 思路一样——只是 Vue 有

provide/inject

兜底,Flutter 这一层是

InheritedWidget

(后面再学)。

为什么 Flutter 不像 Vue 那样自动更新?

因为 Vue 用响应式追踪(getter/setter 或 Proxy),变量变了视图自动 patch;Flutter 选择

显式触发

——你写

setState

,框架就知道这一片要重 build。代价是多敲一行代码,好处是数据流非常明确,看到

setState

就知道这里会触发刷新。

Color?

返回

null

是什么意思?

TextStyle.color

不传就是用主题默认色。这比 Vue 里返回空字符串 class 优雅得多——不要写

Colors.transparent

Colors.black

,让它

继承主题

,深色模式下才会正常变白。

三、任务 2:GitHub 用户搜索 ⭐⭐⭐

这是最重要的一个练习。

真实业务中 80% 的页面都长这样

:输入条件 → 发请求 → 渲染列表 → 处理三态(loading / error / empty)。把这一个吃透,剩下的都是变体。

需求

顶部搜索框输入关键字(如

flutter

调用

https://api.github.com/search/users?q=xxx

显示头像 + 用户名 的列表

处理三种状态:

加载中 / 出错 / 空结果

支持下拉刷新

对照 Vue:

就是你写过无数次的

axios + v-if loading / error / data

页面。

关键学习点

Future

/

async-await

(≈ Promise)

http

包(要在

pubspec.yaml

里加依赖)

FutureBuilder

处理三态

——这是 Flutter 的标志性模式,理解了它就理解了"声明式异步"

ListView.builder

+

Image.network

RefreshIndicator

Vue 版本(

GithubSearch.vue

Flutter 版本(

github_search_page.dart

先在

pubspec.yaml

加依赖:

dependencies:

http:

^1.2.0

完整代码:

import

'dart:convert'

;

import

'package:flutter/material.dart'

;

import

'package:http/http.dart'

as

http;

class

GithubUser

{

final

int

id;

final

String

login;

final

String

avatarUrl;

GithubUser({

required

this

.id,

required

this

.login,

required

this

.avatarUrl});

factory

GithubUser.fromJson(

Map

<

String

,

dynamic

j) => GithubUser(

id: j[

'id'

]

as

int

,

login: j[

'login'

]

as

String

,

avatarUrl: j[

'avatar_url'

]

as

String

,

);

}

class

GithubSearchPage

extends

StatefulWidget

{

const

GithubSearchPage({

super

.key});

@override

State createState() => _GithubSearchPageState();

}

class

_GithubSearchPageState

extends

State

<

GithubSearchPage

{

final

_ctrl = TextEditingController();

Future<

List

>? _future;

@override

void

dispose() {

_ctrl.dispose();

super

.dispose();

}

Future<

List

> _search(

String

kw)

async

{

final

uri =

Uri

.parse(

'https://api.github.com/search/users?q=

$kw

'

);

final

res =

await

http.

get

(uri);

if

(res.statusCode !=

200

) {

throw

Exception(

'HTTP

${res.statusCode}

'

);

}

final

json = jsonDecode(res.body)

as

Map

<

String

,

dynamic

;

final

items = (json[

'items'

]

as

List

).cast<

Map

<

String

,

dynamic

();

return

items.map(GithubUser.fromJson).toList();

}

void

_onSearch() {

final

kw = _ctrl.text.trim();

if

(kw.isEmpty)

return

;

setState(() => _future = _search(kw));

}

@override

Widget build(BuildContext context) {

return

Scaffold(

appBar: AppBar(title:

const

Text(

'GitHub 用户搜索'

)),

body: Column(

children: [

Padding(

padding:

const

EdgeInsets.all(

12

),

child: Row(children: [

Expanded(

child: TextField(

controller: _ctrl,

decoration:

const

InputDecoration(

hintText:

'关键字'

,

border: OutlineInputBorder(),

isDense:

true

,

),

onSubmitted: (_) => _onSearch(),

),

),

const

SizedBox(width:

8

),

ElevatedButton(onPressed: _onSearch, child:

const

Text(

'搜索'

)),

]),

),

Expanded(

child: _future ==

null

?

const

Center(child: Text(

'请输入关键字开始搜索'

))

: FutureBuilder<

List

>(

future: _future,

builder: (ctx, snap) {

if

(snap.connectionState != ConnectionState.done) {

return

const

Center(child: CircularProgressIndicator());

}

if

(snap.hasError) {

return

Center(child: Text(

'出错了:

${snap.error}

'

,

style:

const

TextStyle(color: Colors.red)));

}

final

users = snap.data ?? [];

if

(users.isEmpty) {

return

const

Center(child: Text(

'无结果'

));

}

return

RefreshIndicator(

onRefresh: ()

async

=> _onSearch(),

child: ListView.builder(

itemCount: users.length,

itemBuilder: (c, i) {

final

u = users[i];

return

ListTile(

leading: CircleAvatar(

backgroundImage: NetworkImage(u.avatarUrl),

),

title: Text(u.login),

);

},

),

);

},

),

),

],

),

);

}

}

void

main() => runApp(

const

MaterialApp(home: GithubSearchPage()));

FutureBuilder

的心智模型(这是这一篇最值钱的内容)

Vue 里你习惯这样写:

const

loading =

ref

(

false

)

const

error =

ref

(

''

)

const

data =

ref

(

null

)

// 然后在三个 v-if 之间手动来回切

3 个变量,每次请求要在 4 个生命周期点(开始/成功/失败/finally)手动维护它们

。漏一个 finally 就会出 bug。

Flutter 的

FutureBuilder

直接换了一个角度:

"你给我一个 Future,我帮你监听它的状态变化,每次状态变了我都会调你给我的 builder,你只管根据当前状态画 UI。"

所以你只要存

一个

Future

Future<

List

>? _future;

然后在

builder

里"声明式地"描述每个状态长什么样:

FutureBuilder<

List

>(

future: _future,

builder: (ctx, snap) {

if

(snap.connectionState != ConnectionState.done)

return

Loading();

// 加载中

if

(snap.hasError)

return

ErrorView(snap.error);

// 出错

if

(snap.data!.isEmpty)

return

Empty();

// 空

return

List

(snap.data!);

// 数据

},

)

一次性把 4 种状态都"声明"出来

,再也不会忘

finally

。Vue 里要做到等价效果得引

@vueuse/core

useAsyncState

,Flutter 这是开箱即用。

实际开发的小提示

Future?

是可空的

——初始时

null

,表示"还没搜索过"。这就是 Vue 里你额外加

searched

变量的原因,Flutter 用 nullable 一招解决。

每次点搜索是新建一个 Future

,不是修改老的——这点和 Vue 习惯不一样,但更安全:旧请求即使返回了也不会被新 UI 用到。

RefreshIndicator

onRefresh

必须

await

,否则下拉的转圈不会消失。

四、任务 3:个人中心综合页 ⭐⭐⭐

把前面学的 Widget 都拿出来攒一个像样的页面。这个任务没有新知识点,但

强迫你练习"组件拆分"

——这是 Flutter 工程化最关键的一步。

需求

顶部大头像 + 昵称 + 简介(类似 GitHub Profile)

中间一行统计卡片:「关注 | 粉丝 | 仓库」

下方 TabBar:「Repos | Stars | Followers」三个 Tab,分别是列表

右上角设置图标,点击进入空白设置页

对照 Vue:

类似 element-plus 的

  • 卡片组合。

关键学习点

DefaultTabController

+

TabBar

+

TabBarView

三件套

抽取

私有小组件

_ProfileHeader

_StatCard

)——下划线开头表示文件私有

Navigator.push

跳转新页面

Vue 版本(

ProfilePage.vue

Flutter 版本(

profile_page.dart

import

'package:flutter/material.dart'

;

class

ProfilePage

extends

StatelessWidget

{

const

ProfilePage({

super

.key});

@override

Widget build(BuildContext context) {

return

DefaultTabController(

length:

3

,

child: Scaffold(

appBar: AppBar(

title:

const

Text(

'个人中心'

),

actions: [

IconButton(

icon:

const

Icon(Icons.settings),

onPressed: () => Navigator.push(

context,

MaterialPageRoute(builder: (_) =>

const

SettingsPage()),

),

),

],

bottom:

const

TabBar(tabs: [

Tab(text:

'Repos'

),

Tab(text:

'Stars'

),

Tab(text:

'Followers'

),

]),

),

body: Column(

children: [

const

_ProfileHeader(

avatar:

'https://picsum.photos/120'

,

name:

'Alice'

,

bio:

'一个正在学 Flutter 的前端'

,

),

Padding(

padding:

const

EdgeInsets.symmetric(vertical:

8

, horizontal:

16

),

child: Row(

children:

const

[

_StatCard(value:

'42'

, label:

'关注'

),

_StatCard(value:

'99'

, label:

'粉丝'

),

_StatCard(value:

'17'

, label:

'仓库'

),

],

),

),

const

Divider(height:

1

),

Expanded(

child: TabBarView(

children: [

_SimpleList(items:

const

[

'repo-1'

,

'repo-2'

,

'repo-3'

]),

_SimpleList(items:

const

[

'vue'

,

'flutter'

,

'dart'

]),

_SimpleList(items:

const

[

'bob'

,

'carol'

,

'dave'

]),

],

),

),

],

),

),

);

}

}

class

_ProfileHeader

extends

StatelessWidget

{

final

String

avatar;

final

String

name;

final

String

bio;

const

_ProfileHeader({

required

this

.avatar,

required

this

.name,

required

this

.bio});

@override

Widget build(BuildContext context) {

return

Padding(

padding:

const

EdgeInsets.all(

16

),

child: Column(

children: [

CircleAvatar(radius:

48

, backgroundImage: NetworkImage(avatar)),

const

SizedBox(height:

8

),

Text(name, style:

const

TextStyle(fontSize:

20

, fontWeight: FontWeight.bold)),

const

SizedBox(height:

4

),

Text(bio, style:

const

TextStyle(color: Colors.grey)),

],

),

);

}

}

class

_StatCard

extends

StatelessWidget

{

final

String

value;

final

String

label;

const

_StatCard({

required

this

.value,

required

this

.label});

@override

Widget build(BuildContext context) {

return

Expanded(

child: Column(

children: [

Text(value, style:

const

TextStyle(fontSize:

18

, fontWeight: FontWeight.bold)),

Text(label, style:

const

TextStyle(color: Colors.grey, fontSize:

12

)),

],

),

);

}

}

class

_SimpleList

extends

StatelessWidget

{

final

List

<

String

items;

const

_SimpleList({

required

this

.items});

@override

Widget build(BuildContext context) {

return

ListView.builder(

itemCount: items.length,

itemBuilder: (c, i) => ListTile(

leading:

const

Icon(Icons.folder_open),

title: Text(items[i]),

),

);

}

}

class

SettingsPage

extends

StatelessWidget

{

const

SettingsPage({

super

.key});

@override

Widget build(BuildContext context) {

return

Scaffold(

appBar: AppBar(title:

const

Text(

'设置'

)),

body:

const

Center(child: Text(

'(空白)'

)),

);

}

}

void

main() => runApp(

const

MaterialApp(home: ProfilePage()));

Vue 老司机要适应的两件事

组件拆分没有"单文件组件"概念

Vue 里一个组件 = 一个

.vue

文件,习惯了"一文件一组件"。Flutter 里只要类名以下划线开头(

_ProfileHeader

),就是

文件私有

的,完全可以堆在同一个文件里。

实战经验:

只在本页用的小组件就用

_

开头堆同文件

,被多处复用的再单独拎出去。不要上来就拆 10 个文件。

DefaultTabController

是什么妖魔鬼怪?

它就是"Tab 的状态托管器"——把"当前选中第几个 Tab"这个 state 提到一个共同祖先上,让

TabBar

TabBarView

都能读到。这跟 Vue 里 element-plus 的

是一模一样的事情,只是 Flutter 把它显式化了。

如果想自己控制(比如外部按钮切 Tab),就把

DefaultTabController

换成手动

TabController

,跟 Vue 自己声明一个

ref(0)

同理。

查看原文


🏷 标签: Vue, Flutter, 前端跨框架迁移, StatefulWidget, 主题切换