原文地址:PJ 的 iOS 開發之路
歷時一個星期對 Flutter 一期調研在這篇文章中就告一段落了,這篇文章中繼續完善上篇文章中利用豆瓣電影 Top250 公開 API demo。
前言
在這兩三天的繼續完善 demo 的時間中,首先是對 Flutter 在基本 UI 視覺方面的實現表示讚賞,有些地方的 UI 佈局的程式碼編寫習慣了 Flutter 的思維後,會有一個非常快速的反應。下面是具體 demo 具體的完成圖:
整體 demo 做完後,全程都是在使用 meizu 15 這臺開發機進行除錯,在 flutter 的 IDE 選擇上一直都在使用 Android Studio
,在斷點除錯、檢視渲染節點、效能對比等活動上都非常方便的解決了,依然強推!但整體沒有遵循 Flutter 官方推薦的 BloC
設計模式,還是採用“設計模式之王”的 MVC
,同樣是考慮到了後續在對比其它跨段方案時儘可能的保證一致性。
資料來源
豆瓣電影詳情 API 同樣不需要做驗證,傳入對應電影的 id 即可,但會限制同一 IP 在一定間隔時間內的訪問次數,如果在一定間隔時間內容訪問 API 的速度過於頻繁,則會拒絕服務,不過得益於 Flutter 的 hot reload
技術,可以不需要每次都重新拉去資料。該詳情 API 多了一些更具體的資料,但依然沒有達到豆瓣 App 本身那般豐富。
涉及 Flutter 知識點
- 下拉重新整理;
- 上拉載入;
- 利用
GestureDetector Widget
進行頁面跳轉(動態路由方式); - 利用
SingleChildScrollView Widget
進行滾動檢視的構建; - 簡單效能分析。
實踐
目錄結構
資料處理
電影詳情 API 返回的欄位更多,同樣可以確認的是 Model 也一定要從網路資料來源中進行拋離,這同樣也為後續構建子元件回填資料時提供方便,我的電影詳情 Model 如下所示:
class MovieMember {
String id;
// 成員姓名
String name;
// 詳情 URL
String detailUrl;
// 中清晰度頭像
String avatarUrl;
MovieMember({
this.name,
this.detailUrl,
this.avatarUrl,
});
MovieMember.fromJSON(Map<String, dynamic> json) {
this.id = json['id'];
this.avatarUrl = json['avatars']['medium'];
this.detailUrl = json['alt'];
this.name = json['name'];
}
}
class MovieDetail {
// 標題
String title;
// 上映年份
String year;
// 原名
String originalTitle;
// 所屬國家或地區
List<String> countries;
// 評分
String rating;
// "想看"人數
String wishCount;
// 星星
String stars;
// 高清晰度海報
String poster;
// 電影型別
List<String> genres;
// 評分人數
int ratingsCount;
// 主要導演
List<MovieMember> director;
// 主要演員
List<MovieMember> casts;
// 簡介
String summary;
MovieDetail({
this.title,
this.year,
this.countries,
this.rating,
this.stars,
this.poster,
this.genres,
this.ratingsCount,
this.director,
this.casts,
this.summary,
this.wishCount,
});
MovieDetail.fromJSON(Map<String, dynamic> json) {
this.title = json['title'];
this.year = json['year'];
this.summary = json['summary'];
this.poster = json['images']['large'];
this.ratingsCount = json['ratings_count'];
this.originalTitle = json['original_title'];
this.wishCount = json['wish_count'].toString();
this.rating = json['rating']['average'].toString();
this.stars = json['rating']['stars'].toString();
this.countries = new List<String>.from(json['countries']);
this.genres = new List<String>.from(json['genres']);
List<MovieMember> castsMembers = [];
(json['directors'] as List).forEach((item) {
MovieMember movieMember = MovieMember.fromJSON(item);
castsMembers.add(movieMember);
});
this.director = castsMembers;
List<MovieMember> directorMembers = [];
(json['casts'] as List).forEach((item) {
MovieMember movieMember = MovieMember.fromJSON(item);
directorMembers.add(movieMember);
});
this.casts = directorMembers;
}
}
在寫 MovieDetail
Model 時發現電影詳情 API 返回資料來源中的“演員”資料存在多欄位必要資料,為了後續方便呼叫同樣也抽離了一個 MovieMember
Model(二期調研估計會繼續做演員詳情)。
網路資料的獲取因為有了上篇文章的鋪墊,這次再寫一個速度明顯快了很多,一期調研完整的網路層方法如下:
import 'dart:io';
import 'dart:convert';
import 'package:movie_top_250/Model/movieModel.dart';
class MovieAPI {
Future<Movies> getMovieList(int start) async {
var client = HttpClient();
int page = start * 50;
var request = await client.getUrl(Uri.parse(
'https://api.douban.com/v2/movie/top250?start=$page&count=50'));
var response = await request.close();
var responseBody = await response.transform(utf8.decoder).join();
Map data = json.decode(responseBody);
return Movies.fromJSON(data);
}
Future<MovieDetail> getMovieDetail(String movieId) async {
var client = HttpClient();
var request = await client.getUrl(Uri.parse(
'https://api.douban.com/v2/movie/subject/$movieId'));
var response = await request.close();
var responseBody = await response.transform(utf8.decoder).join();
Map data = json.decode(responseBody);
return MovieDetail.fromJSON(data);
}
}
下拉重新整理與上拉載入
下拉重新整理的整體與 Native 開發思路一致。上拉載入有根據業務有很多種實現方案,因為只是為了做驗證性 demo ,直接採取了使用“靜默載入”的思路,當然因為一次載入資料量太大(一頁 50 條),所以在快速滑動列表時會導致下一頁資料未載入等待的體驗。如果不考慮過多的定製化操作,直接使用 flutter 系統元件是一件非常舒服的事情。完整程式碼如下:
import 'package:flutter/material.dart';
import 'package:movie_top_250/Service/movieApi.dart';
import 'package:movie_top_250/Model/movieModel.dart';
import 'package:movie_top_250/View/List/movieListViewRowWidget.dart';
class MovieWidget extends StatefulWidget {
@override
State<StatefulWidget> createState() {
return _DouBanMovieState();
}
}
class _DouBanMovieState extends State<MovieWidget> {
// 資料來源
List<Movie> movies = [];
// 分頁
int page = 0;
@override
void initState() {
super.initState();
_requestData();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('豆瓣電影 Top250'),
),
body: new RefreshIndicator(
child: _buildList(context),
onRefresh: _requestData,
color: Colors.black,
),
);
}
// 下拉重新整理
Future<Null> _requestData() async {
movies.clear();
await MovieAPI().getMovieList(0).then((moviesData) {
setState(() {
movies = moviesData.movies;
});
});
return;
}
// 上拉載入
_requestMoreData(int page) {
print('page = $page');
MovieAPI().getMovieList(page).then((moviesData) {
setState(() {
movies += moviesData.movies;
});
});
}
// body List Widget
Widget _buildList(BuildContext context) {
var screenWidth = MediaQuery.of(context).size.width;
if (movies.length != 0) {
return ListView.separated(
itemBuilder: (context, index) {
// 還剩 15 條資料的時去拉取新資料
if (movies.length - index == 15) {
_requestMoreData(++page);
}
return new Container(
width: screenWidth,
child: buildListRow(index, movies[index], context),
);
},
separatorBuilder: (context, index) => Divider(
height: 1,
),
itemCount: movies.length);
} else {
return Center(
child: CircularProgressIndicator(),
);
}
}
}
UI 分析
上文中也已經說到,因為豆瓣電影詳情公開 API 所暴露出的資料有限,導致未能 100% 的重寫。經過分析後主要將頁面分為了以下幾部分:
第一部分
第一部分與上篇文章中所講述的佈局編寫思路大部分一致,對於我自己來說有個需要注意的地方,在第一部分中有個“豆瓣電影排名”的 badge
,原本打算是用 RichText Widget
進行實現的,但翻完屬性後發現並沒有提供 decoration
欄位進行修飾,最後直接使用了兩個 DecoratedBox Widget
作為父容器,在其 decoration
屬性下使用 BoxDecoration Widget
完成“一左一右”圓角的 badge
元件編寫,flutter 在元件“半圓角”的實現過程比 iOS 原生實現的程式碼量上少太多了(不封裝的話),實現程式碼如下:
Widget _buildBadge(int index, MovieDetail movieDetail) {
index++;
return new Row(
children: <Widget>[
DecoratedBox(
child: Padding(
padding: EdgeInsets.fromLTRB(7, 3, 7, 3),
child: Text('No.$index',
style: TextStyle(color: Colors.brown, fontSize: 14))),
decoration: new BoxDecoration(
color: Colors.orangeAccent,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(5),
bottomLeft: Radius.circular(5)))),
DecoratedBox(
child: Padding(
padding: EdgeInsets.fromLTRB(7, 3, 7, 3),
child: Text('豆瓣Top250',
style: TextStyle(color: Colors.orangeAccent, fontSize: 12))),
decoration: new BoxDecoration(
color: Colors.black45,
borderRadius: BorderRadius.only(
topRight: Radius.circular(5),
bottomRight: Radius.circular(5)))),
],
);
}
在實現“想看”和“看過”兩個按鈕元件時,我使用了 RaisedButton Widget
。一開始是這麼寫的:
new RaisedButton(
onPressed: null,
color: Colors.white,
child: new Text('B'),
textColor: Colors.black,
)
當顯示出來後,不管怎麼調整樣式、修改顏色、文字等都不管用。最後帶著鬱悶的心情瀏覽官方文件,居然發現了這麼一段話:
嗯,就算我們並不想讓這個 Button 響應任何點選事件也不能給這個屬性置空,並且也不能刪除,因為這是個必須引數......這點需要注意。讓同樣也沒想到的是 RaisedButton
沒有 text
或類似設定按鈕文字的屬性,而是給了一個 child
屬性,被 UIButton
虐過幾次後在 Flutter
中看到某個元件提供了 child
屬性現在就兩眼放光!完成的程式碼如下:
Widget _buildButton() {
return new Padding(
padding: EdgeInsets.fromLTRB(0, 20, 0, 0),
child: new Row(
children: <Widget>[
new Padding(
padding: EdgeInsets.fromLTRB(0, 0, 10, 0),
child: new RaisedButton(
onPressed: () {},
color: Colors.white,
child: new Row(
children: <Widget>[
new Padding(
padding: EdgeInsets.fromLTRB(0, 0, 5, 0),
child: new Icon(
Icons.remove_red_eye,
size: 18,
color: Colors.orange,
)
),
new Text('想看',
style: new TextStyle(
color: Color.fromRGBO(100, 100, 100, 1),
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
],
),
textColor: Colors.black,
),
),
new RaisedButton(
onPressed: () {},
color: Colors.white,
child: new Row(
children: <Widget>[
new Padding(
padding: EdgeInsets.fromLTRB(0, 0, 5, 0),
child: new Icon(
Icons.star_border,
size: 18,
color: Colors.orange,
)
),
new Text('看過',
style: new TextStyle(
color: Color.fromRGBO(100, 100, 100, 1),
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
],
),
textColor: Colors.black,
),
],
),
);
}
第二部分
這部分佈局與上篇文章所講述的內容都是一致的。並且我也偷懶了,主要是沒有太多值得花費心思的地方,都是常規的佈局.本來想實現下“進度條”,但無奈並沒有真實資料,也就懶得弄了。完整程式碼如下:
import 'package:flutter/material.dart';
import 'package:movie_top_250/Model/movieModel.dart';
Widget movieDetailStarWidget(MovieDetail movieDetail) {
return new DecoratedBox(
decoration: new BoxDecoration(
color: Color.fromRGBO(65, 46, 37, 1),
borderRadius: BorderRadius.all(Radius.circular(5))),
child: new Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
new Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
new Padding(
padding: EdgeInsets.all(10),
child: new Text(
'豆瓣評分™',
style: TextStyle(color: Colors.white),
)
)
],
),
_buildRatingStar(movieDetail),
],
)
);
}
Widget _buildRatingStar(MovieDetail movieDetail) {
List<Widget> icons = [];
int fS = int.parse(movieDetail.stars) ~/ 10;
int f = 0;
while (f < fS) {
icons.add(new Icon(Icons.star, color: Colors.orange, size: 15));
f++;
}
while (icons.length != 5) {
icons.add(new Icon(Icons.star,
color: Color.fromRGBO(220, 220, 220, 1), size: 15));
}
return new Padding(
padding: EdgeInsets.fromLTRB(0, 5, 0, 10),
child: new Column(children: <Widget>[
new Padding(
padding: EdgeInsets.fromLTRB(5, 0, 0, 10),
child: new Text(
movieDetail.rating,
style: new TextStyle(
fontSize: 35,
fontWeight: FontWeight.w500,
color: Color.fromRGBO(220, 220, 220, 1),
),
),
),
new Row(
mainAxisAlignment: MainAxisAlignment.center,
children: icons
),
]),
);
}
第三部分
這部分涉及到了長文字,flutter 中同樣也沒有提供長文字顯示元件,但好在 flutter 的 Text Widget
本身就適用於長文字展示的元件,預設開啟 softWrap
屬性(自動換行)。Text Widget
會從自身節點樹裡一直向上尋找能夠提供寬度約束的父元件,並以此作為單行文字最長顯示寬度,這點還是比較驚訝的,省了非常多的事情。完整的程式碼如下:
import 'package:flutter/material.dart';
import 'package:movie_top_250/Model/movieModel.dart';
Widget movieDetailSummaryWidget(MovieDetail movieDetail) {
return new Padding(
padding: EdgeInsets.all(15),
child: new Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
new Padding(
padding: EdgeInsets.fromLTRB(0, 0, 0, 10),
child: new Text(
'簡介',
style: new TextStyle(
fontSize: 20,
color: Colors.white,
fontWeight: FontWeight.w600,
),
)
),
new Text(
movieDetail.summary,
style: new TextStyle(
color: Colors.white,
fontSize: 15,
)
)
],
)
);
}
當資料展示出來後,發現超出當前頁面的顯示範圍了,這也是意料之中。按照之前的做法,會把 UIScrollView
作為當前頁面所有原始的父容器,等所有 UI 元素都回填資料重新渲染完後,再把位於最底部的 UI 元件 bottom 值賦給 scrollView.contentSize
。
翻了 flutter 文件後,發現了提供常規滑動檢視能力的 Widget 不只一個,最後選擇了 SingleChildScrollView
。本以為設定滑動區域的步驟也會向在 Native 中那般麻煩,但實際上只需要把需要滑動檢視元件的父節點賦給 child
屬性即可,SingleChildScrollView
同樣會自動的擴充自己的滑動區域進行適配,如下所示:
Widget _buildBody(BuildContext context) {
// 資料來源沒來時展示 loading
if (movieDetail == null) {
return new Center(
child: new CircularProgressIndicator(),
);
} else {
return new SingleChildScrollView(
child: new Padding(
padding: EdgeInsets.all(10),
child: new Column(children: <Widget>[
movieDetailHeaderWidget(rankIndex, movieDetail, context),
movieDetailStarWidget(movieDetail),
movieDetailSummaryWidget(movieDetail),
movieDetailMemberWidget(movieDetail),
])
),
);
}
}
第四部分
這部分是整體比較糾結的地方,到底是基於 GridView Widget
還是 SingleChildScrollView Widget
配合著其它佈局 Widget 去做呢?如果這在 iOS 中,我會毫不猶豫的選擇 UICollectionView
進行構建,因為又快又好~
最後還是抱著“又快又好”目的出發,選擇了 SingleChildScrollView Widget
配合著其它佈局 Widget 去做。需要注意是的 SingleChildScrollView Widget
預設是縱向滾動,該部分豆瓣 App 進行的橫行滾動,需要改變滾動檢視方式。完整程式碼如下:
import 'package:flutter/material.dart';
import 'package:movie_top_250/Model/movieModel.dart';
Widget movieDetailMemberWidget(MovieDetail movieDetail) {
List<Widget> memberWidgets = [];
for (MovieMember member in movieDetail.director) {
memberWidgets.add(_buildMemberWidget(member, true));
}
for (MovieMember member in movieDetail.casts) {
memberWidgets.add(_buildMemberWidget(member, false));
}
return new SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: new Row(
children: memberWidgets,
),
);
}
Widget _buildMemberWidget(MovieMember member, bool isDirector) {
var col = new Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
new Container(
width: 110,
height: 160,
decoration: new BoxDecoration(
image: DecorationImage(image: NetworkImage(member.avatarUrl)),
borderRadius: new BorderRadius.all(
const Radius.circular(8.0),
),
),
),
new Text(member.name,
style: new TextStyle(
color: Colors.white,
),
),
],
);
if (isDirector) {
col.children.add(
new Text(
'導演',
style: new TextStyle(
fontSize: 13,
color: Color.fromRGBO(150, 150, 150, 1)
),
)
);
} else {
col.children.add(
new Text(
'演員',
style: new TextStyle(
fontSize: 13,
color: Color.fromRGBO(150, 150, 150, 1)
),
)
);
}
return new Container(
width: 110,
child: new Padding(
padding: EdgeInsets.all(15),
child: col,
),
);
}
分析工具
在本 demo 中的 ListView Widget
未做太多優化的地方,所以會導致在啟動 App 完成後直接開始上拉頁面會體驗到卡頓,但實際上的做法是先把當前頁資料來源的條數給 ListView
設定上,當使用者停止滑動頁面後,再開始 load 當前在可視區域範圍內的 ListViewRow Widget
相關子元件資料。這一點優化在 iOS 上通過 UITableView
配合 RunLoop
就可以解決,但在對 flutter 的一期調研中並未打算開始此項調優工作。
使用各種跨平臺工具最令人感到窒息的莫過於除錯了,基於 JSCore
比如 Weex
、React-Native
等框架還行,能夠利用 web 開發者工具搭配進行。但比如 Xamarin
、Qt
等框架想要進行除錯基本上就比較費勁了,如果框架開發者不提供一些功能完備的 IDE 或外掛,除錯幾乎等於噩夢。好在 Android Stuido
對 flutter 的支援是相當豐富,具體見下圖:
其它
webView
今天原本還想做跳轉 webView
,以為也只是直接調個 webView Widget
,填入 requestUrl
屬性就完事了。但當我輸入 web ,IDE 並未提示任何相關資訊時,開始發覺有點不太對勁,不會 flutter 沒有提供 webView Widget
吧?仔細瀏覽後,確認 flutter 還真的沒有提供官方 webView
元件,但在 Pub 上已經有了對應的外掛。
接著去掘金的 flutter 交流群裡諮詢,討論在 flutter 中 webView
以及 JSBridge
最佳思路,最後討論出了兩個外掛:
- webView 外掛(帶 JSBridge):https://pub.flutter-io.cn/packages/interactive_webview
- webView 外掛:https://pub.dartlang.org/packages/flutter_webview_plugin
對於 webView
這塊不是特別滿意,而且看了 flutter 在 github 上的 issue,推薦自己做一個 webView plugin
,暴露給 flutter 進行呼叫,這樣可以最大程度上的降低基礎元件重寫成本。仔細一想,其實還是不滿意,所以這部分內容也延後到二期調研中了。
StatefulWidget
和 StatelessWidget
的選擇
對於開發一個新的元件時,到底是基於 StatefulWidget
還是 StatelessWidget
,我認為只需要明確兩個概念即可:
- 是否需要更新元件資料來源;
- 是否需要利用元件各種生命週期;
如果以上兩個條件都符合,那就選擇 StatefulWidget
。
總結
原始碼地址
本次 flutter 一期調研學習產出的豆瓣電影 Top250 demo 連結地址:movie_top_250
flutter 的這種“宣告式”編碼體驗,我認為對於第一次接觸的新手來說,肯定有需要一定的學習成本,當逼迫自己去熟悉開發思路後,就會覺得真的很過癮。在一期調研的學習中,沒有涉及到的方面有:
- 設計模式;
webView
及JSBridge
;- flutter 與 native 的互動;
- 複雜 UI 的構建;
- 音視訊處理(這還是得通過 native 進行暴露);
以上幾塊是構建一個 App 所需要具備的基本元件。經過一期學習後,對 flutter 也有了自己的理解,給我最大的感受是因為不用像 React-Native
、Weex
等需要回撥節點樹給中間層通知 native 利用原生 UI 框架進行渲染,首先在 UI 繪製速度上已經遠超其它框架,這點是毋庸置疑的。但因為 flutter 還太年輕,一些基礎設施和社群都做得不算太好。所以如果非要選擇一個跨端技術投入實際開發中,我還是會選擇 React-Native
,所以將重新撿起來 React-Native
, 對其同樣重新進行一期調研。