发布时间:2019年11月23日 - 9分钟阅读
介绍
作为一个开发者,我们认为维护多个代码库做同一件事是很痛苦的,这就是为什么Jetbrains提出了Kotlin Multiplatform项目,Google提出了Flutter,Facebook提出了React Native。Kotlin Multiplatform旨在解决业务逻辑部分,而后者旨在解决端到端的问题;从业务逻辑开始一直到用户界面。我曾经尝试过Kotlin Multiplatform,虽然它在Kotlin Native和Coroutines上有一些限制,但它的工作效果还是不错的。你可以在这里查看我的文章。
在我的下一个旅程中,我决定用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设置为项目名称。
现在设置包名,勾选AndroidX、Kotlin支持和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/?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.dart和core_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.dart和movie_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的实现
它是如何工作的
- 我们先创建一个简单的模型,叫做
Movie模型。 - 我们准备的变换接口叫
Mapper,这个接口的目的很简单,就是把JSON字符串变换成对象模型。 - 我们创建一个名为
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.dart和movie_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");
}
}
}
电影云服务实施
它是如何工作的
- 我们创建服务接口。这个接口的目的是在通过
ViewModel调用时提供抽象。你可以使用这个接口从云端或本地缓存中获取数据(例如)。 - 我们创建服务实现。在这个例子中,我们通过提供
apiKey和搜索查询连接到OMDB服务。来自http的结果是JSON String,然后传递给我们的mapper接口并返回一个对象模型;在本例中是一个Movie模型。 - 如果响应不是200,我们就直接抛出一个错误。
视图模型
现在我们已经完成了http服务的准备工作,我们将编写ViewModel类。这个类的目的是作为UI和Service之间来回流转数据的主要通道。
在 core的pubspect.yaml 中添加RxDart(如果你没有)的依赖关系,然后创建2个新的类,分别叫做list_view_model.dart和get_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实现
它是如何工作的
- 我们先创建ViewModel接口。我们准备了2个输入:
start和loadMore,以及3个输出:loading、result和exception。通过只向我们的UI平台提供这些信息,我们能够分离关注和类的责任。UI只需调用输入,并获取输出,仅此而已。 - 这里是提供数据给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来合并两个流并返回电影列表。 - 接下来我们使用
BehaviorSubject准备我们的属性。为什么不使用PublishSubject呢,我后面会解释。 - 最后我们提供
dispose()方法,并清除所有的属性以防止泄漏。
observable.sink.add相当于其他反应式语言中的observable.onNext,doOnData相当于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
它是如何工作的
- 我们先创建
ViewModel,并提供ViewModel需要的所有依赖关系。你也可以使用依赖注入来提供ViewModel。 - 我们将
ViewModel传递到MovieList的widget中。
创建Movie List小部件
接下来我们创建电影列表小部件和它的项目小部件。创建2个文件,名为movie_list.dart和movie_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
它是如何工作的
- 我们使用
StreamBuilder来订阅ViewModel的输出。在这个例子中,我们只订阅到1个输出,即:widget.viewModel.output.result。在实际应用中,我们还需要订阅widget.viewModel.output.exception来将错误信息传递给用户,或者widget.viewModel.output.loading来显示加载指标。 - 如果你还记得为什么我们要使用
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中克隆快速启动项目。
复制到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。创建一个名为src和di的新目录作为子目录,添加名为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中注入依赖关系。
它是如何工作的
- 我们创建依赖模块类,并提供
GetMovieListViewModel所需的所有实现。 - 我们把所有的提供者放到列表中,首先我们提供
http.Client和MovieMapper以及key和host信息,因为我们的CloudMovieService需要这些信息。接下来我们使用FactoryProvider分别提供Service和ViewModel。 - 我们将
viewModel注入AppComponent构造函数中。 - 在
ngOnInit里面,我们开始订阅viewModel.output.result和viewModel.output.loading,并将结果传递到List<Movie> movies和bool loading中,app_component.html将使用它们。 - 订阅后,我们开始搜索名为复仇者的电影列表。
- 我们在
ngOnDestroy()期间处理我们的订阅。
viewModel.output.result.listen相当于其他反应式语言中的viewModel.output.result.subscribe(onNext:)。
AngularDart提供了AsyncPipe来工作在Observable上。但是在测试过程中,使用AsyncPipe导致我的html页面无限循环运行。在做了一些研究之后(当然是用google),我发现了这篇文章解释了为什么会发生这种情况。这篇文章是日语,不过你可以像我一样用google翻译😁:
这就是为什么我决定不使用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
它是如何工作的
- 我们正在使用标准的css样式来创建一个电影列表。
- 在HTML中,我们使用
ngSwitch检查加载变量,如果为真,则显示加载文本,否则显示默认文本。 - 接下来我们在电影上循环,并将信息显示给我们的用户。
使用webdev serve运行,你应该会看到我们的目标部分所显示的布局。
如果你在更新依赖关系后收到构建错误,只需关闭webdev,运行webdev build,然后重新运行webdev serve。
总结
平台间的代码共享是未来的趋势。如果你按照我的Kotlin多平台教程,你应该会看到在ViewModel中使用Reactive方法实现了相同的业务逻辑,我相信在不久的将来,我们可以让这一切变得完美,让大家受益。
当Flutter for Web准备好投入生产使用时,本教程可能会过时,但在那之前,我希望这能帮助你实现单一代码库,多平台。
最后,代码在这里:
通过( www.DeepL.com/Translator )(免费版)翻译