[Web翻译]Flutter和AngularDart之间的代码共享

1,036 阅读12分钟

原文地址:medium.com/@fandygotam…

原文作者:medium.com/@fandygotam…

发布时间:2019年11月23日 - 9分钟阅读

介绍

作为一个开发者,我们认为维护多个代码库做同一件事是很痛苦的,这就是为什么Jetbrains提出了Kotlin Multiplatform项目,Google提出了Flutter,Facebook提出了React Native。Kotlin Multiplatform旨在解决业务逻辑部分,而后者旨在解决端到端的问题;从业务逻辑开始一直到用户界面。我曾经尝试过Kotlin Multiplatform,虽然它在Kotlin Native和Coroutines上有一些限制,但它的工作效果还是不错的。你可以在这里查看我的文章。

medium.com/@fandygotam…

在我的下一个旅程中,我决定用Flutter试试。是的,我知道Flutter有Flutter for Web,为什么还要用AngularDart呢?好吧,目前Flutter for Web还在开发者预览版,Google并不推荐在生产中使用。未来是未知的,我们活在当下。所以,我们还是试试使用现在的东西吧😁。

如果你想一边看书一边做代码,本文大约需要30分钟到1小时才能完成。

注意事项:

  • 阅读本教程时,文字之间的粗体字表示你要仔细看。
  • 斜体句子是额外的信息,你可以跳过。

前提条件

我不会详细解释关于Flutter的Widget、AngularDart以及反应式编程的工作原理,因为你很容易从Google找到所有的文档。

在本教程中,至少你有:

  • 了解Flutter StatefulWidget和StatelessWidget的工作原理。
  • 已经完成了Google的AngularDart的tour_of_heroes教程。
  • 基本的Dart编程。
  • 基本了解Reactive Programming的工作原理。

目标

我们的目标是为Flutter和AngularDart创建用纯dart编写的可共享业务逻辑。我们的应用程序将连接到OMDB,获取电影列表,并在Flutter和Web中显示结果。

这就是我们的最终结果。

Flutter

网页浏览器

办法

本教程将使用MVVM模式与输入输出的方法,灵感来自kickstarters,(github.com/kickstarter…

下面是我们的app结构设计图。

MVVM图与输入输出方法

在本教程中,我没有使用干净的架构方法,因为这会使教程变得更加复杂。

在写这篇文章的时候,我正在使用以下库的最新版本。

  • Flutter SDK: 1.12.2 开发频道
  • Dart SDK。2.7.0
  • RxDart: 0.22.6
  • http: 0.12.0+2
  • AngularDart: 6.0.0-alpha 1
  • AngularComponent: 0.14.0-alpha 1

集成开发环境

  • Android Studio 3.5.2 (Flutter)
  • VS代码1.40.1 (AngularDart)

我们开始吧!

我们要使用Android Studio来编写我们的业务逻辑。启动Android Studio,选择新建Flutter项目......,然后选择Flutter应用

选择Flutter应用作为我们的起点。

设置项目名称,例如:flutter_shareable并点击下一步。

将flutter_shareable设置为项目名称。

现在设置包名,勾选AndroidXKotlin支持和Swift支持,然后点击完成。

选择你的包名。

现在我们已经有了Flutter应用程序,我们要创建库,选择文件->新建->新建Flutter项目并选择Flutter包。这将是我们的Pure Dart实现。

选择Flutter包来编写纯Dart的实现。

设置项目名称,如:core,并设置项目位置在flutter_shareable目录内,点击完成。

选择您的项目名称,以便共享代码

下面是我们的项目目录结构。

当前项目目录结构

添加依赖关系到你的Flutter的pubspec.yaml文件。

environment:
  sdk: ">=2.1.0 <3.0.0"

dependencies:
  flutter:
    sdk: flutter
  rxdart: ^0.22.6
  http: ^0.12.0+2
  core:
    path: ./core
    
  # The following adds the Cupertino Icons font to your application.
  # Use with the CupertinoIcons class for iOS style icons.
  cupertino_icons: ^0.1.2

dev_dependencies:
  flutter_test:
    sdk: flutter

Flutter的pubspec.yaml。

商业逻辑

连接到云服务

我们将连接到OMDB并获得电影列表。API是免费的,你可以从这里得到它。

www.omdbapi.com/apikey.aspx

我们将搜索所有包含复仇者关键词的电影与这个网址:www.omdbapi.com/?s=avenger&…

以下是来自API的JSON响应:

{
  "Search": [
    {
      "Title": "Captain America: The First Avenger",
      "Year": "2011",
      "imdbID": "tt0458339",
      "Type": "movie",
      "Poster": "https://m.media-amazon.com/images/M/MV5BMTYzOTc2NzU3N15BMl5BanBnXkFtZTcwNjY3MDE3NQ@@._V1_SX300.jpg"
    },
    {
      "Title": "The Toxic Avenger",
      "Year": "1984",
      "imdbID": "tt0090190",
      "Type": "movie",
      "Poster": "https://m.media-amazon.com/images/M/MV5BNzViNmQ5MTYtMmI4Yy00N2Y2LTg4NWUtYWU3MThkMTVjNjk3XkEyXkFqcGdeQXVyMTQxNzMzNDI@._V1_SX300.jpg"
    }
  ],
  "totalResults": "102",
  "Response": "True"
}

OMDb API响应

现在,让我们开始准备我们的数据模型。

打开核心目录,删除core.dartcore_test.dart,创建新目录model,并创建新文件movie.dart

class Movie {
  final String title;
  final String year;
  final Uri poster;

  Movie({this.title, this.year, this.poster});

  factory Movie.fromJson(Map<String, dynamic> json) {
    return Movie(
        title: json['Title'],
        year: json['Year'],
        poster: Uri.parse(json['Poster'])
    );
  }
}

movie.dart

建议在创建Dart文件时使用小写和_。

lib目录下创建两个新的文件,分别叫mapper.dartmovie_mapper.dart

abstract class Mapper<T, E> {
  E transform(T response);
}

Mapper接口

import 'dart:convert';
import 'mapper.dart';
import 'model/movie.dart';

class MovieMapper implements Mapper<String, List<Movie>> {
  @override
  List<Movie> transform(String response) {
    final parsed = json.decode(response);

    final list = parsed['Search'] as List;

    return list.map<Movie>((json) => Movie.fromJson(json)).toList();
  }
}

Movie Mapper的实现

它是如何工作的

  1. 我们先创建一个简单的模型,叫做Movie模型。
  2. 我们准备的变换接口叫Mapper,这个接口的目的很简单,就是把JSON字符串变换成对象模型。
  3. 我们创建一个名为MovieMapper的实现类

下一步,我们将使用http库从API获取电影列表。打开core的pubspec.yaml,将http添加为依赖项,并删除所有flutter依赖项(重要)。

name: core
description: A pure dart implementation consist of business logic 
version: 0.0.1
author:
homepage:

environment:
  sdk: ">=2.1.0 <3.0.0"

dependencies:
  http: ^0.12.0+2
  rxdart: ^0.22.6

核心的pubspec.yaml。

如果你在导入核心模块中的http或其他库时出现错误,请尝试进入核心目录,通过终端手动运行flutter pub get

创建2个文件:service.dartmovie_cloud_service.dart

abstract class Service<R, T> {
  Future<T> execute(R request);
}

服务接口

import 'service.dart';
import 'package:http/http.dart';
import 'mapper.dart';

class MovieCloudService<T> implements Service<String, T> {
  final String key;
  final String host;
  final Client client;
  final Mapper<String, T> mapper;

  const MovieCloudService({this.client, this.key, this.host, this.mapper});

  @override
  Future<T> execute(String request) async {
    final queries = {'apiKey': key, 's': request};
    final uri = Uri.https(host, '', queries);

    final response = await client.get(uri);

    if (response.statusCode == 200) {
      return mapper.transform(response.body);
    } else {
      return throw Exception("Invalid response");
    }
  }
}

电影云服务实施

它是如何工作的

  1. 我们创建服务接口。这个接口的目的是在通过ViewModel调用时提供抽象。你可以使用这个接口从云端或本地缓存中获取数据(例如)。
  2. 我们创建服务实现。在这个例子中,我们通过提供apiKey和搜索查询连接到OMDB服务。来自http的结果是JSON String,然后传递给我们的mapper接口并返回一个对象模型;在本例中是一个Movie模型
  3. 如果响应不是200,我们就直接抛出一个错误。

视图模型

现在我们已经完成了http服务的准备工作,我们将编写ViewModel类。这个类的目的是作为UI和Service之间来回流转数据的主要通道。

core的pubspect.yaml 中添加RxDart(如果你没有)的依赖关系,然后创建2个新的类,分别叫做list_view_model.dartget_movie_list_view_model.dart

import 'package:rxdart/rxdart.dart';

abstract class ListViewModelInput<R> {
  void start(R request);
  void loadMore(R request);
}

abstract class ListViewModelOutput<T> {
  Observable<bool> get loading;
  Observable<T> get result;
  Observable<Exception> get exception;
}

abstract class ListViewModel<R, T> {
  ListViewModelInput<R> get inputs;
  ListViewModelOutput<T> get outputs;

  void dispose();
}

列表ViewModel接口

import 'package:rxdart/rxdart.dart';
import 'package:core/model/movie.dart';
import 'package:core/service.dart';
import 'list_view_model.dart';

class GetMovieListViewModel<R>
    implements
        ListViewModel<R, List<Movie>>,
        ListViewModelInput<R>,
        ListViewModelOutput<List<Movie>> {

  @override
  ListViewModelInput<R> get inputs => this;

  @override
  ListViewModelOutput<List<Movie>> get outputs => this;

  @override
  Observable<bool> get loading => _loadingProperty.stream;

  @override
  Observable<Exception> get exception => _exceptionProperty.stream;

  @override
  Observable<List<Movie>> get result {
    final items = List<Movie>();
    bool clearItems = false;

    final initialRequest = _startProperty.stream
        .doOnData((_) => _loadingProperty.sink.add(true))
        .switchMap((request) => Observable.fromFuture(service.execute(request)))
        .doOnData((_) {
      _loadingProperty.sink.add(false);
      clearItems = true;
    });

    final nextRequest = _loadMoreProperty.stream
        .doOnData((_) => _loadingProperty.sink.add(true))
        .switchMap((request) => Observable.fromFuture(service.execute(request)))
        .doOnData((_) {
      _loadingProperty.sink.add(false);
      clearItems = false;
    });

    return Observable.merge([initialRequest, nextRequest]).map((response) {
      if (clearItems) {
        items.clear();
      }

      items.addAll(response);

      return items;
    });
  }

  final Service<R, List<Movie>> service;

  final _startProperty = BehaviorSubject<R>();
  final _loadMoreProperty = BehaviorSubject<R>();
  final _loadingProperty = BehaviorSubject<bool>();
  final _exceptionProperty = BehaviorSubject<Exception>();

  GetMovieListViewModel({this.service});

  @override
  void start(R request) {
    _startProperty.sink.add(request);
  }

  @override
  void loadMore(R request) {
    _loadMoreProperty.sink.add(request);
  }

  @override
  void dispose() {
    _loadMoreProperty.close();
    _loadingProperty.close();
    _startProperty.close();
    _exceptionProperty.close();
  }
}

电影列表ViewModel实现

它是如何工作的

  1. 我们先创建ViewModel接口。我们准备了2个输入startloadMore,以及3个输出loadingresultexception。通过只向我们的UI平台提供这些信息,我们能够分离关注和类的责任。UI只需调用输入,并获取输出,仅此而已。
  2. 这里是提供数据给UI显示的所有业务逻辑。首先我们将输入和输出变量分配给这个类,将_loadingProperty.stream分配给loading observable,将_exceptionProperty.stream分配给exception observable。现在我们转到结果observable,在这里我们首先获取_startProperty的输入,并通过调用_loadingProperty.sink.add(true)来显示加载指示器,接下来我们通过调用Observable.fromFuture将我们从服务中获取的未来数据翻译成observable。之后我们通过调用_loadingProperty.sink.add(false)来隐藏加载指标。_loadMoreProperty的逻辑差不多,调用加载,将future翻译成observable并隐藏加载。唯一不同的是,我们在_loadMoreProperty.doOnData中将clearItems变量设置为false,因为我们不想在加载更多滚动过程中重置列表。最后我们调用merge来合并两个流并返回电影列表。
  3. 接下来我们使用BehaviorSubject准备我们的属性。为什么不使用PublishSubject呢,我后面会解释。
  4. 最后我们提供dispose()方法,并清除所有的属性以防止泄漏。

observable.sink.add相当于其他反应式语言中的observable.onNextdoOnData相当于doOnNext

Flutter

主体

现在让我们进入我们的用户界面,打开main.dart,并删除默认的示例代码,用下面的代码代替。

import 'package:core/movie_mapper.dart';
import 'package:core/movie_cloud_service.dart';
import 'package:core/get_movie_list_view_model.dart';
import 'package:flutter/material.dart';
import 'package:flutter_shareable/movie_list.dart';
import 'package:http/http.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  final client = Client();

  get movieListViewModel {
    final mapper = MovieMapper();
    final service = MovieCloudService(
        client: client,
        key: "xxxxxxx",
        host: "www.omdbapi.com",
        mapper: mapper);

    return GetMovieListViewModel<String>(service: service);
  }

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: Scaffold(
        appBar: AppBar(
          title: Text('Flutter Demo'),
        ),
        body: MovieList(viewModel: movieListViewModel),
      ),
    );
  }
}

main.dart

它是如何工作的

  1. 我们先创建ViewModel,并提供ViewModel需要的所有依赖关系。你也可以使用依赖注入来提供ViewModel
  2. 我们将ViewModel传递到MovieList的widget中。

创建Movie List小部件

接下来我们创建电影列表小部件和它的项目小部件。创建2个文件,名为movie_list.dartmovie_cell.dart

import 'package:flutter/material.dart';
import 'package:core/list_view_model.dart';
import 'package:core/model/movie.dart';

import 'movie_cell.dart';

class MovieList extends StatefulWidget {
  final ListViewModel<String, List<Movie>> viewModel;

  const MovieList({Key key, this.viewModel}) : super(key: key);

  @override
  _MovieListState createState() => _MovieListState();
}

class _MovieListState extends State<MovieList> {
  final GlobalKey<RefreshIndicatorState> _refreshIndicatorKey =
      GlobalKey<RefreshIndicatorState>();

  @override
  void dispose() {
    widget.viewModel.dispose();

    super.dispose();
  }

  @override
  void initState() {
    super.initState();

    WidgetsBinding.instance
        .addPostFrameCallback((_) => _refreshIndicatorKey.currentState.show());
  }

  @override
  Widget build(BuildContext context) {
    return RefreshIndicator(
      key: _refreshIndicatorKey,
      onRefresh: _refresh,
      child: StreamBuilder(
          stream: widget.viewModel.outputs.result,
          builder: (context, AsyncSnapshot<List<Movie>> snapshot) {
            if (snapshot.data == null) return Container();

            final list = snapshot.data;

            return GridView.builder(
              gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
                  crossAxisCount: 2,
                  crossAxisSpacing: 5,
                  mainAxisSpacing: 5,
                  childAspectRatio: 0.7),
              itemCount: list.length,
              itemBuilder: (context, index) {
                if (index == list.length - 2) {
                  widget.viewModel.inputs.loadMore("avenger");
                }

                return MovieCell(movie: list[index]);
              },
            );
          }),
    );
  }

  Future<Null> _refresh() async {
    widget.viewModel.inputs.start("avenger");

    return null;
  }
}

电影列表widget

import 'package:flutter/material.dart';
import 'package:core/model/movie.dart';

class MovieCell extends StatelessWidget {
  final Movie movie;

  const MovieCell({Key key, this.movie}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Card(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: <Widget>[
          Expanded(
            child: Image.network(
              movie.poster.toString(),
              fit: BoxFit.cover,
            ),
          ),
          Padding(
            padding: EdgeInsets.all(5),
            child: Text(movie.title, textAlign: TextAlign.center),
          )
        ],
      ),
    );
  }
}

电影项目widget

它是如何工作的

  1. 我们使用StreamBuilder来订阅ViewModel的输出。在这个例子中,我们只订阅到1个输出,即:widget.viewModel.output.result。在实际应用中,我们还需要订阅widget.viewModel.output.exception来将错误信息传递给用户,或者widget.viewModel.output.loading来显示加载指标。
  2. 如果你还记得为什么我们要使用BehaviorSubject而不是PublishSubject,是因为我们的输入可以开始运行,甚至在我们订阅输出之前就开始运行了。在这种情况下:widget.viewModel.inputs.start在订阅发生之前首先调用的。使用PublishSubject会在你加载屏幕时显示空屏幕。试着把BehaviorSubject改成PublishSubject,在initState()里面注释WidgetsBinding,然后把widget.viewModel.inputs.start移到initState()里面。你不会在模拟器屏幕上看到电影列表,改回BehaviorSubject可以解决这个问题:
@override
  void initState() {
    super.initState();

    widget.viewModel.inputs.start("avenger");
 
    //WidgetsBinding.instance
    //    .addPostFrameCallback((_) => _refreshIndicatorKey.currentState.show());
  }

尝试使用PublishSubject直接调用输入

运行你的Flutter App,你应该会看到我们的Goal部分的类似输出。

不要忘记在onDestroy()期间调用dispose(),以防止内存泄漏!

AngularDart

现在让我们来研究一下AngularDart,从Google repo中克隆快速启动项目。

github.com/angular-exa…

复制到flutter_shareable项目,并改名为angular

你的目录结构应该是这样的。

将 quickstart 改名为 angular

打开VS Code,然后选择angular目录(不要选择flutter项目),打开pubspec.yaml,添加core library,并将所有的依赖关系更新到最新版本。

name: angular_app
description: A web app that uses AngularDart
version: 0.0.1

environment:
  sdk: '>=2.2.0 <3.0.0'

dependencies:
  angular: ^6.0.0-alpha+1
  rxdart: ^0.22.6
  http: ^0.12.0+2
  meta: ^1.1.8
  angular_components: ^0.14.0-alpha+1
  core:
    path: ../core
  
dev_dependencies:
  angular_test: ^2.4.0
  build_runner: ^1.7.2
  build_test: ^0.10.9+1
  build_web_compilers: ^2.1.0
  test: ^1.9.4

Angular的pubspec.yaml。

接下来,我们将通过Angular的依赖注入提供我们的ViewModel。创建一个名为srcdi的新目录作为子目录,添加名为movie_module.dart的新文件。

import 'package:angular/core.dart';
import 'package:core/get_movie_list_view_model.dart';
import 'package:core/list_view_model.dart';
import 'package:core/service.dart';
import 'package:core/model/movie.dart';
import 'package:core/mapper.dart';
import 'package:core/movie_cloud_service.dart';

import 'package:http/http.dart' as http;

const key = OpaqueToken<String>("key");
const host = OpaqueToken<String>("host");

Service<String, List<Movie>> provideService(
  http.Client client, 
  @key String key, 
  @host String host, Mapper mapper) {
  return MovieCloudService(
    client: client, 
    key: key, 
    host: host, 
    mapper: mapper);
}

ListViewModel<String, List<Movie>> provideViewModel(Service service) {
  return GetMovieListViewModel<String>(service: service);
}

电影模块提供实现类

依赖注入目录

现在,打开app_component.dart,并添加以下代码。

import 'package:angular/angular.dart';
import 'package:core/list_view_model.dart';
import 'package:core/movie_mapper.dart';
import 'package:core/service.dart';
import 'package:core/mapper.dart';
import 'package:core/model/movie.dart';

import 'src/di/movie_module.dart';

import 'package:http/http.dart' as http;

@Component(
  selector: 'my-app',
  templateUrl: 'app_component.html',
  directives: [coreDirectives],
  providers: [
    ClassProvider(http.Client),
    ClassProvider(Mapper, useClass: MovieMapper),
    ValueProvider.forToken(key, "b445ca0b"),
    ValueProvider.forToken(host, "www.omdbapi.com"),
    FactoryProvider(Service, provideService),
    FactoryProvider(ListViewModel, provideViewModel)
  ],
  styleUrls: [
    'package:angular_components/app_layout/layout.scss.css',
    'package:angular_components/css/mdc_web/card/mdc-card.scss.css',
    'app_component.css',
  ]
)
class AppComponent implements OnInit, OnDestroy {
  AppComponent(this.viewModel);

  final ListViewModel viewModel;

  List<Movie> movies;
  bool loading;
  
  @override
  void ngOnInit() {
    viewModel.outputs.result.listen((data) => movies = data);
    viewModel.outputs.loading.listen((isLoading) => loading = isLoading);

    viewModel.inputs.start('avenger');
  }

  @override
  void ngOnDestroy() {
    viewModel.dispose();
  }
}

在app_component.dart中注入依赖关系。

它是如何工作的

  1. 我们创建依赖模块类,并提供GetMovieListViewModel所需的所有实现。
  2. 我们把所有的提供者放到列表中,首先我们提供http.ClientMovieMapper以及key和host信息,因为我们的CloudMovieService需要这些信息。接下来我们使用FactoryProvider分别提供Service和ViewModel
  3. 我们将viewModel注入AppComponent构造函数中。
  4. ngOnInit里面,我们开始订阅viewModel.output.resultviewModel.output.loading,并将结果传递到List<Movie> moviesbool loading中,app_component.html将使用它们。
  5. 订阅后,我们开始搜索名为复仇者的电影列表。
  6. 我们在ngOnDestroy()期间处理我们的订阅。

viewModel.output.result.listen相当于其他反应式语言中的viewModel.output.result.subscribe(onNext:)

AngularDart提供了AsyncPipe来工作在Observable上。但是在测试过程中,使用AsyncPipe导致我的html页面无限循环运行。在做了一些研究之后(当然是用google),我发现了这篇文章解释了为什么会发生这种情况。这篇文章是日语,不过你可以像我一样用google翻译😁:

ntaoo.hatenablog.com/entry/2019/…

这就是为什么我决定不使用AsyncPipe,而是手动监听Observable

HTML & CSS

这不是我的专长,因为我已经很久没有接触过这两样东西了,不过为了完成这个教程,我们继续吧。

lib目录下创建app_component.css

.movie-size {
    width: 300px;
}

.container {
    display: flex;
    flex-direction: row;
    flex-wrap: wrap;
}

app_component的样式

并在同一目录下创建app_component.html

<header class="material-header">
    <div class="material-header-row">
        <div [ngSwitch]="loading">
            <span *ngSwitchCase="true" class="material-header-title">Movie List - Loading</span>
            <span *ngSwitchCase="false" class="material-header-title">Movie List</span>
        </div>
        <div class="material-spacer"></div>
    </div>
</header>
<material-content>
<div class="container">
    <div *ngFor="let movie of movies" style="margin: 20px;">
        <section>
            <div class="mdc-card movie-size">
                <img src="{{ movie.poster }}" class="mdc-card__media mdc-card__media--16-9" />
                <div class="demo-card__primary">
                    <h2 class="demo-card__title" style="margin: 20px;">{{ movie.title }}</h2>
                </div>
            </div>
        </section>
    </div>
</div>
</material-content>

app_component的html

它是如何工作的

  1. 我们正在使用标准的css样式来创建一个电影列表。
  2. 在HTML中,我们使用ngSwitch检查加载变量,如果为真,则显示加载文本,否则显示默认文本。
  3. 接下来我们在电影上循环,并将信息显示给我们的用户。

使用webdev serve运行,你应该会看到我们的目标部分所显示的布局。

如果你在更新依赖关系后收到构建错误,只需关闭webdev,运行webdev build,然后重新运行webdev serve

总结

平台间的代码共享是未来的趋势。如果你按照我的Kotlin多平台教程,你应该会看到在ViewModel中使用Reactive方法实现了相同的业务逻辑,我相信在不久的将来,我们可以让这一切变得完美,让大家受益。

当Flutter for Web准备好投入生产使用时,本教程可能会过时,但在那之前,我希望这能帮助你实现单一代码库,多平台。

最后,代码在这里:

github.com/gotamafandy…


通过( www.DeepL.com/Translator )(免费版)翻译