65.9K
CodeProject 正在变化。 阅读更多。
Home

使用 ScopedModel 在 Flutter 中实现 MVVM

starIconstarIconstarIconstarIconstarIcon

5.00/5 (11投票s)

2018 年 8 月 20 日

CPOL

3分钟阅读

viewsIcon

34445

使用 ScopedModel 在 Flutter 中实现 MVVM 模式。

引言

在本文中,我将演示如何使用 scoped_model 在 Flutter 中实现 MVVM 模式。Scoped model 是一个实用工具,它允许将一个响应式模型传递给 ScopedModel widget 及其后代。通过简单地调用该实用工具的 notifyListeners() 方法,在模型中,您可以启动等效于调用 setState() 的操作,并导致 ScopedModelDescendant widget 重建——如果您对这个解释感到困惑,请不要担心,当您阅读本文项目的代码时,您将会更好地理解它。

正如您从上面的屏幕截图中看到的,示例应用程序显示了与星球大战相关的数据列表:特别是电影、角色和行星。该应用程序从 Swapi,即星球大战 API 获取这些数据。由于 Swapi 是一个开放 API,因此在发出 HTTP 请求时不需要身份验证,因此您不必担心获取 API 密钥。

必备组件

要跟随本文,您应该熟悉 FlutterMVVM 模式。您还应该从 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_modelModel 类,并且是调用 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 日:更新代码
© . All rights reserved.