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

第 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

关键学习点

  • StatefulWidgetsetState 的最小用法
  • MaterialApptheme 切换
  • 条件样式(在 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());

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

  1. 为什么主题切换的 state 要放在 CounterApp 而不是 CounterPage 因为 MaterialApp.themeCounterApp 那一层,state 必须在"会用到它的最近共同祖先"上 。这跟 Vue 里 prop drill 思路一样------只是 Vue 有 provide/inject 兜底,Flutter 这一层是 InheritedWidget(后面再学)。

  2. 为什么 Flutter 不像 Vue 那样自动更新? 因为 Vue 用响应式追踪(getter/setter 或 Proxy),变量变了视图自动 patch;Flutter 选择显式触发 ------你写 setState,框架就知道这一片要重 build。代价是多敲一行代码,好处是数据流非常明确,看到 setState 就知道这里会触发刷新。

  3. Color? 返回 null 是什么意思? TextStyle.color 不传就是用主题默认色。这比 Vue 里返回空字符串 class 优雅得多------不要写 Colors.transparentColors.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

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/coreuseAsyncState,Flutter 这是开箱即用。

实际开发的小提示

  • Future<T>? 是可空的 ------初始时 null,表示"还没搜索过"。这就是 Vue 里你额外加 searched 变量的原因,Flutter 用 nullable 一招解决。
  • 每次点搜索是新建一个 Future,不是修改老的------这点和 Vue 习惯不一样,但更安全:旧请求即使返回了也不会被新 UI 用到。
  • RefreshIndicatoronRefresh 必须 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 老司机要适应的两件事

  1. 组件拆分没有"单文件组件"概念 Vue 里一个组件 = 一个 .vue 文件,习惯了"一文件一组件"。Flutter 里只要类名以下划线开头(_ProfileHeader),就是文件私有 的,完全可以堆在同一个文件里。 实战经验:只在本页用的小组件就用 _ 开头堆同文件,被多处复用的再单独拎出去。不要上来就拆 10 个文件。

  2. DefaultTabController 是什么妖魔鬼怪? 它就是"Tab 的状态托管器"------把"当前选中第几个 Tab"这个 state 提到一个共同祖先上,让 TabBarTabBarView 都能读到。这跟 Vue 里 element-plus 的 <el-tabs v-model="tab"> 是一模一样的事情,只是 Flutter 把它显式化了。 如果想自己控制(比如外部按钮切 Tab),就把 DefaultTabController 换成手动 TabController,跟 Vue 自己声明一个 ref(0) 同理。


相关推荐
dreamsever7 小时前
OpenTelemetry可观测系统之Metrics学习
java·前端·学习
Bacon7 小时前
装上就回不去了:CodeGraph 让 AI 编程效率飙升 92%,它到底做了什么?
前端·人工智能·后端
hadeas7 小时前
Spring 技术栈学习文档(面向前端开发者)
前端
狗头大军之江苏分军7 小时前
Python 协程进化史:从 yield 到 async/await 的底层实现
前端·后端
jay神8 小时前
基于YOLOv8的交通标志识别Web系统
前端·人工智能·深度学习·yolo·机器学习·毕业设计
CAD老兵8 小时前
一张 HTML 走天下:CAD-Viewer 首创的「离线 CAD 看图」
前端·javascript·github
程序员榴莲8 小时前
Python 中的 @property:像访问属性一样调用方法
开发语言·前端·python
yingyima8 小时前
Linux定时任务:crontab vs systemd timer,到底谁更适合你的业务?
前端
有味道的男人9 小时前
1688 跨境 API:多语言、跨境代采、独立站商品同步方案
java·服务器·前端