第 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)
vue
<template>
<div class="page" :class="{ dark: dark }">
<div class="row">
<span>深色模式</span>
<input type="checkbox" v-model="dark" />
</div>
<h1 :class="colorClass">{{ count }}</h1>
<div class="row">
<button @click="count--">-</button>
<button @click="count++">+</button>
<button @click="count = 0">重置</button>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const count = ref(0)
const dark = ref(false)
const colorClass = computed(() => {
if (count.value > 10) return 'green'
if (count.value < 0) return 'red'
return ''
})
</script>
<style scoped>
.page { padding: 24px; text-align: center; }
.page.dark { background: #222; color: #eee; }
.row { display: flex; gap: 8px; justify-content: center; align-items: center; }
.green { color: seagreen; }
.red { color: crimson; }
h1 { font-size: 64px; }
</style>
Flutter 版本(counter_page.dart)
dart
import 'package:flutter/material.dart';
class CounterApp extends StatefulWidget {
const CounterApp({super.key});
@override
State<CounterApp> 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<CounterPage> 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.networkRefreshIndicator
Vue 版本(GithubSearch.vue)
vue
<template>
<div class="page">
<input v-model="kw" placeholder="搜索 GitHub 用户" @keyup.enter="search" />
<button @click="search">搜索</button>
<p v-if="loading">加载中...</p>
<p v-else-if="error" class="err">出错了:{{ error }}</p>
<p v-else-if="users.length === 0 && searched">无结果</p>
<ul v-else>
<li v-for="u in users" :key="u.id">
<img :src="u.avatar_url" width="40" />
<span>{{ u.login }}</span>
</li>
</ul>
</div>
</template>
<script setup>
import { ref } from 'vue'
const kw = ref('')
const users = ref([])
const loading = ref(false)
const error = ref('')
const searched = ref(false)
async function search() {
if (!kw.value.trim()) return
loading.value = true
error.value = ''
searched.value = true
try {
const res = await fetch(`https://api.github.com/search/users?q=${kw.value}`)
const data = await res.json()
users.value = data.items || []
} catch (e) {
error.value = e.message
} finally {
loading.value = false
}
}
</script>
<style scoped>
.page { padding: 16px; max-width: 480px; margin: 0 auto; }
li { display: flex; align-items: center; gap: 8px; padding: 6px 0; }
.err { color: red; }
</style>
Flutter 版本(github_search_page.dart)
先在 pubspec.yaml 加依赖:
yaml
dependencies:
http: ^1.2.0
完整代码:
dart
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<GithubSearchPage> createState() => _GithubSearchPageState();
}
class _GithubSearchPageState extends State<GithubSearchPage> {
final _ctrl = TextEditingController();
Future<List<GithubUser>>? _future;
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
Future<List<GithubUser>> _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<GithubUser>>(
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 里你习惯这样写:
js
const loading = ref(false)
const error = ref('')
const data = ref(null)
// 然后在三个 v-if 之间手动来回切
3 个变量,每次请求要在 4 个生命周期点(开始/成功/失败/finally)手动维护它们。漏一个 finally 就会出 bug。
Flutter 的 FutureBuilder 直接换了一个角度:
"你给我一个 Future,我帮你监听它的状态变化,每次状态变了我都会调你给我的 builder,你只管根据当前状态画 UI。"
所以你只要存一个 Future:
dart
Future<List<GithubUser>>? _future;
然后在 builder 里"声明式地"描述每个状态长什么样:
dart
FutureBuilder<List<GithubUser>>(
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<T>?是可空的 ------初始时null,表示"还没搜索过"。这就是 Vue 里你额外加searched变量的原因,Flutter 用 nullable 一招解决。- 每次点搜索是新建一个 Future,不是修改老的------这点和 Vue 习惯不一样,但更安全:旧请求即使返回了也不会被新 UI 用到。
RefreshIndicator的onRefresh必须await,否则下拉的转圈不会消失。
四、任务 3:个人中心综合页 ⭐⭐⭐
把前面学的 Widget 都拿出来攒一个像样的页面。这个任务没有新知识点,但强迫你练习"组件拆分"------这是 Flutter 工程化最关键的一步。
需求
- 顶部大头像 + 昵称 + 简介(类似 GitHub Profile)
- 中间一行统计卡片:「关注 | 粉丝 | 仓库」
- 下方 TabBar:「Repos | Stars | Followers」三个 Tab,分别是列表
- 右上角设置图标,点击进入空白设置页
对照 Vue: 类似 element-plus 的 <el-tabs> + 卡片组合。
关键学习点
DefaultTabController+TabBar+TabBarView三件套- 抽取私有小组件 (
_ProfileHeader、_StatCard)------下划线开头表示文件私有 Navigator.push跳转新页面
Vue 版本(ProfilePage.vue)
vue
<template>
<div class="profile">
<header>
<img class="avatar" :src="user.avatar" />
<h2>{{ user.name }}</h2>
<p>{{ user.bio }}</p>
</header>
<div class="stats">
<div><b>{{ user.following }}</b><span>关注</span></div>
<div><b>{{ user.followers }}</b><span>粉丝</span></div>
<div><b>{{ user.repos }}</b><span>仓库</span></div>
</div>
<div class="tabs">
<button v-for="(t,i) in tabs" :key="i" :class="{active:tab===i}" @click="tab=i">{{ t }}</button>
</div>
<ul>
<li v-for="item in current" :key="item">{{ item }}</li>
</ul>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const user = {
avatar: 'https://picsum.photos/120',
name: 'Alice',
bio: '一个正在学 Flutter 的前端',
following: 42, followers: 99, repos: 17,
}
const tabs = ['Repos', 'Stars', 'Followers']
const tab = ref(0)
const data = [
['repo-1','repo-2','repo-3'],
['vue','flutter','dart'],
['bob','carol','dave'],
]
const current = computed(() => data[tab.value])
</script>
Flutter 版本(profile_page.dart)
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 的<el-tabs v-model="tab">是一模一样的事情,只是 Flutter 把它显式化了。 如果想自己控制(比如外部按钮切 Tab),就把DefaultTabController换成手动TabController,跟 Vue 自己声明一个ref(0)同理。