使用 ScopedModel 在 Flutter 中实现 MVVM





5.00/5 (11投票s)
使用 ScopedModel 在 Flutter 中实现 MVVM 模式。


引言
在本文中,我将演示如何使用 scoped_model 在 Flutter 中实现 MVVM 模式。Scoped model 是一个实用工具,它允许将一个响应式模型传递给 ScopedModel
widget 及其后代。通过简单地调用该实用工具的 notifyListeners()
方法,在模型中,您可以启动等效于调用 setState()
的操作,并导致 ScopedModelDescendant
widget 重建——如果您对这个解释感到困惑,请不要担心,当您阅读本文项目的代码时,您将会更好地理解它。
正如您从上面的屏幕截图中看到的,示例应用程序显示了与星球大战相关的数据列表:特别是电影、角色和行星。该应用程序从 Swapi,即星球大战 API 获取这些数据。由于 Swapi 是一个开放 API,因此在发出 HTTP 请求时不需要身份验证,因此您不必担心获取 API 密钥。
必备组件
要跟随本文,您应该熟悉 Flutter 和 MVVM 模式。您还应该从 GitHub 克隆或下载该项目。
依赖项
正如我在介绍部分中提到的,该项目使用了 ScopedModel
,因此 scoped_model
包的依赖项被添加到 *pubsec.yaml* 文件中。我还使用了 font_awesome_flutter 包和 Distant Galaxy 字体:后者用于应用程序栏中的标题文本,而前者用于显示大多数图标。
name: flutter_mvvm_example
description: Flutter MVVM example project.
dependencies:
flutter:
sdk: flutter
...
scoped_model: ^0.3.0
font_awesome_flutter: ^8.0.1
...
flutter:
...
fonts:
- family: Distant Galaxy
fonts:
- asset: fonts/DISTGRG_.ttf
模型
该项目中有三个模型类,代表从星球大战 API 获取的数据。您可以在 *lib > models* 文件夹中找到它们。
class Film {
String title, openingCrawl, director, producer;
DateTime releaseDate;
Film({
this.title,
this.openingCrawl,
this.director,
this.producer,
this.releaseDate,
});
Film.fromMap(Map<String, dynamic> map) {
title = map['title'];
openingCrawl = map['opening_crawl'];
director = map['director'];
producer = map['producer'];
releaseDate = DateTime.parse(map['release_date']);
}
}
class Character {
String name, birthYear, gender, eyeColor;
int height;
Character({
this.name,
this.birthYear,
this.gender,
this.height,
this.eyeColor,
});
Character.fromMap(Map<String, dynamic> map) {
name = map['name'];
birthYear = map['birth_year'];
gender = map['gender'];
height = int.parse(map['height']);
eyeColor = map['eye_color'];
}
}
class Planet {
String name, climate, terrain, gravity, population;
int diameter;
Planet({
this.name,
this.climate,
this.terrain,
this.diameter,
this.gravity,
this.population,
});
Planet.fromMap(Map<String, dynamic> map) {
name = map['name'];
climate = map['climate'];
terrain = map['terrain'];
diameter = int.parse(map['diameter']);
gravity = map['gravity'];
population = map['population'];
}
}
视图模型
项目中只有一个 view model,即 MainPageViewModel
。此类扩展了 scoped_model
的 Model
类,并且是调用 notifyListeners()
方法的地方。
import 'dart:async';
import 'package:meta/meta.dart';
import 'package:flutter_mvvm_example/interfaces/i_star_wars_api.dart';
import 'package:scoped_model/scoped_model.dart';
import 'package:flutter_mvvm_example/models/film.dart';
import 'package:flutter_mvvm_example/models/character.dart';
import 'package:flutter_mvvm_example/models/planet.dart';
class MainPageViewModel extends Model {
Future<List<Film>> _films;
Future<List<Film>> get films => _films;
set films(Future<List<Film>> value) {
_films = value;
notifyListeners();
}
Future<List<Character>> _characters;
Future<List<Character>> get characters => _characters;
set characters(Future<List<Character>> value) {
_characters = value;
notifyListeners();
}
Future<List<Planet>> _planets;
Future<List<Planet>> get planets => _planets;
set planets(Future<List<Planet>> value) {
_planets = value;
notifyListeners();
}
final IStarWarsApi api;
MainPageViewModel({@required this.api});
Future<bool> setFilms() async {
films = api?.getFilms();
return films != null;
}
Future<bool> setCharacters() async {
characters = api?.getCharacters();
return characters != null;
}
Future<bool> setPlanets() async {
planets = api?.getPlanets();
return planets != null;
}
}
notifyListeners()
方法在 setter 中被调用——如果您熟悉 WPF,特别是 WPF 中的 MVVM,那么我所做的事情类似于在 view model 的属性的 setter 中调用 OnPropertyChanged()
。
可以调用 MainPageViewModel
中的异步函数来获取所需的数据并设置必要的属性,这将触发任何 ScopedModelDescendant<MainPageViewModel>
widget 的重建。
获取数据
我已经编写了一个服务,用于从星球大战 API 获取所需的数据,您可以在 *lib > services* 文件夹中找到它。该服务实现了 IStarWarsApi
,它定义了用于获取某些类型的数据集合的函数。
import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:flutter_mvvm_example/interfaces/i_star_wars_api.dart';
import 'package:flutter_mvvm_example/models/character.dart';
import 'package:flutter_mvvm_example/models/film.dart';
import 'package:flutter_mvvm_example/models/planet.dart';
class SwapiService implements IStarWarsApi {
final _baseUrl = 'https://swapi.co/api';
static final SwapiService _internal = SwapiService.internal();
factory SwapiService () => _internal;
SwapiService.internal();
Future<dynamic> _getData(String url) async {
var response = await http.get(url);
var data = json.decode(response.body);
return data;
}
Future<List<Film>> getFilms() async {
var data = await _getData('$_baseUrl/films');
List<dynamic> filmsData = data['results'];
List<Film> films = filmsData.map((f) => Film.fromMap(f)).toList();
return films;
}
Future<List<Character>> getCharacters() async {
var data = await _getData('$_baseUrl/people');
List<dynamic> charactersData = data['results'];
List<Character> characters =
charactersData.map((c) => Character.fromMap(c)).toList();
return characters;
}
Future<List<Planet>> getPlanets() async {
var data = await _getData('$_baseUrl/planets');
List<dynamic> planetsData = data['results'];
List<Planet> planets = planetsData.map((p) => Planet.fromMap(p)).toList();
return planets;
}
}
视图
正如您从文章开头的屏幕截图中看到的,该应用程序有一个 scaffold,其中包含带有“星球大战”标题文本的应用程序栏和一个带有三个选项卡的选项卡栏。此 scaffold 在名为 MainPage
的 widget 中定义。
class MainPage extends StatefulWidget {
@override
_MainPageState createState() => _MainPageState();
}
class _MainPageState extends State<MainPage> with SingleTickerProviderStateMixin {
MainPageViewModel viewModel;
TabController tabController;
@override
void initState() {
super.initState();
viewModel = MainPageViewModel(api: SwapiService());
tabController = TabController(vsync: this, length: 3);
loadData();
}
Future loadData() async {
await model.fetchFilms();
await model.fetchCharacters();
await model.fetchPlanets();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
centerTitle: true,
title: Text(
'Star Wars',
style: TextStyle(
fontFamily: 'Distant Galaxy',
),
),
bottom: TabBar(
controller: tabController,
indicatorColor: Colors.white,
indicatorWeight: 3.0,
tabs: <Widget>[
Tab(icon: Icon(FontAwesomeIcons.film)),
Tab(icon: Icon(FontAwesomeIcons.users)),
Tab(icon: Icon(FontAwesomeIcons.globeAmericas))
],
),
),
body: ScopedModel<MainPageViewModel>(
model: viewModel,
child: TabBarView(
controller: tabController,
children: <Widget>[
FilmsPanel(),
CharactersPanel(),
PlanetsPanel(),
],
),
),
);
}
@override
void dispose() {
tabController?.dispose();
super.dispose();
}
}
TabView
被包裹在 ScopedModel
widget 中,因此它的后代(被包裹在 ScopeModelDescendant
widget 中)将可以访问 MainPageViewModel
类型的 data model,并且每当调用 notifyListeners()
时将被重建。
FilmsPanel
widget 包含显示星球大战电影列表的 ListView
。
import 'package:flutter/material.dart';
import 'package:flutter_mvvm_example/models/film.dart';
import 'package:flutter_mvvm_example/view_models/main_page_view_model.dart';
import 'package:flutter_mvvm_example/views/widgets/films_list_item.dart';
import 'package:flutter_mvvm_example/views/widgets/no_internet_connection.dart';
import 'package:scoped_model/scoped_model.dart';
class FilmsPanel extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ScopedModelDescendant<MainPageViewModel>(
builder: (context, child, model) {
return FutureBuilder<List<Film>>(
future: model.films,
builder: (_, AsyncSnapshot<List<Film>> snapshot) {
switch (snapshot.connectionState) {
case ConnectionState.none:
case ConnectionState.active:
case ConnectionState.waiting:
return Center(child: const CircularProgressIndicator());
case ConnectionState.done:
if (snapshot.hasData) {
var films = snapshot.data;
return ListView.builder(
itemCount: films == null ? 0 : films.length,
itemBuilder: (_, int index) {
var film = films[index];
return FilmsListItem(film: film);
},
);
} else if (snapshot.hasError) {
return NoInternetConnection(
action: () async {
await model.setFilms();
await model.setCharacters();
await model.setPlanets();
},
);
}
}
},
);
},
);
}
}
FilmsListItem
是一个 widget,用于显示 ListView
中的一个项目。
import 'package:flutter/material.dart';
import 'package:flutter_mvvm_example/models/film.dart';
import 'package:flutter_mvvm_example/utils/star_wars_styles.dart';
class FilmsListItem extends StatelessWidget {
final Film film;
FilmsListItem({@required this.film});
@override
Widget build(BuildContext context) {
var title = Text(
film?.title,
style: TextStyle(
color: StarWarsStyles.titleColor,
fontWeight: FontWeight.bold,
fontSize: StarWarsStyles.titleFontSize,
),
);
var subTitle = Row(
children: <Widget>[
Icon(
Icons.movie,
color: StarWarsStyles.subTitleColor,
size: StarWarsStyles.subTitleFontSize,
),
Container(
margin: const EdgeInsets.only(left: 4.0),
child: Text(
film?.director,
style: TextStyle(
color: StarWarsStyles.subTitleColor,
),
),
),
],
);
return Column(
children: <Widget>[
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 20.0),
title: title,
subtitle: subTitle,
),
Divider(),
],
);
}
}
您可以查看 *lib > views > widgets* 文件夹中用于显示角色和行星的其余 widget。
结论
就是这样!我希望你从这篇文章中学到了一些有用的东西。如果您还没有下载文章项目,请确保您这样做并阅读代码。
历史
- 2018 年 8 月 20 日:首次发布
- 2018 年 10 月 10 日:更新代码