案例地址:Flutter_豆瓣案例
一. 數(shù)據(jù)請(qǐng)求和轉(zhuǎn)化
1.1. 首頁(yè)數(shù)據(jù)請(qǐng)求轉(zhuǎn)化
豆瓣數(shù)據(jù)的獲取
這里我使用豆瓣的API接口來(lái)請(qǐng)求數(shù)據(jù):

模型對(duì)象的封裝
在面向?qū)ο蟮拈_(kāi)發(fā)中,數(shù)據(jù)請(qǐng)求下來(lái)并不會(huì)像前端那樣直接使用,而是封裝成模型對(duì)象:
- 前端開(kāi)發(fā)者很容易沒(méi)有面向?qū)ο蟮乃季S或者類型的思維。
- 但是目前前端開(kāi)發(fā)正在向TypeScript發(fā)展,也在幫助我們強(qiáng)化這種思維方式。
為了方便之后使用請(qǐng)求下來(lái)的數(shù)據(jù),我將數(shù)據(jù)劃分成了如下的模型:
Person、Actor、Director模型:它們會(huì)被使用到MovieItem中,home_model.dart代碼如下:
class Person {
String name;
String avatarURL;
Person.fromMap(Map<String, dynamic> json) {
this.name = json["name"];
this.avatarURL = json["avatars"]["medium"];
}
}
class Actor extends Person {
Actor.fromMap(Map<String, dynamic> json): super.fromMap(json);
}
class Director extends Person {
Director.fromMap(Map<String, dynamic> json): super.fromMap(json);
}
int counter = 1;
class MovieItem {
int rank;
String imageURL;
String title;
String playDate;
double rating;
List<String> genres;
List<Actor> casts;
Director director;
String originalTitle;
MovieItem.fromMap(Map<String, dynamic> json) {
// 電影排名
this.rank = counter++;
this.imageURL = json["images"]["medium"];
this.title = json["title"];
this.playDate = json["year"];
this.rating = json["rating"]["average"];
this.genres = json["genres"].cast<String>();
// casts里面是演員,轉(zhuǎn)成List后,就可以使用map方法將演員map轉(zhuǎn)成演員模型
this.casts = (json["casts"] as List<dynamic>).map((item) {
return Actor.fromMap(item);
}).toList();
this.director = Director.fromMap(json["directors"][0]);
this.originalTitle = json["original_title"];
}
@override
// 重寫(xiě)這個(gè)方法以后,打印的時(shí)候會(huì)把模型的所有屬性都信息都打印出來(lái)
String toString() {
return 'MovieItem{rank: $rank, imageURL: $imageURL, title: $title, playDate: $playDate, rating: $rating, genres: $genres, casts: $casts, director: $director, originalTitle: $originalTitle}';
}
}
補(bǔ)充:鼠標(biāo)選中MovieItem,按command+n,可以快速生成構(gòu)造器以及toString方法。

首頁(yè)數(shù)據(jù)請(qǐng)求封裝以及模型轉(zhuǎn)化
這里我封裝了一個(gè)專門(mén)的類,用于請(qǐng)求首頁(yè)的數(shù)據(jù),這樣讓我們的請(qǐng)求代碼更加規(guī)范的管理:HomeRequest。
- 目前類中只有一個(gè)方法requestMovieList;
- 后續(xù)有其他首頁(yè)數(shù)據(jù)需要請(qǐng)求,就繼續(xù)在這里封裝請(qǐng)求的方法;
import 'package:learn_flutter/douban/model/home_model.dart';
import 'config.dart';
import 'http_request.dart';
class HomeRequest {
// 類方法,返回一個(gè)Future
static Future<List<MovieItem>> requestMovieList(int start) async {
// 1.構(gòu)建URL
final movieURL = "/movie/top250?start=$start&count=${HomeConfig.movieCount}";
// 2.發(fā)送網(wǎng)絡(luò)請(qǐng)求獲取結(jié)果
final result = await HttpRequest.request(movieURL);
final subjects = result["subjects"];
// 3.將Map轉(zhuǎn)成Model
List<MovieItem> movies = [];
for (var sub in subjects) {
movies.add(MovieItem.fromMap(sub));
}
return movies;
}
}
在home_content.dart文件中請(qǐng)求數(shù)據(jù)

二. 界面效果實(shí)現(xiàn)
2.1. 首頁(yè)整體代碼
首頁(yè)整體布局非常簡(jiǎn)單,使用一個(gè)ListView即可。
import 'package:flutter/material.dart';
import 'package:learn_flutter/douban/model/home_model.dart';
import 'package:learn_flutter/_06_service//home_request.dart';
import 'home_movie_item.dart';
class HYHomeContent extends StatefulWidget {
@override
_HYHomeContentState createState() => _HYHomeContentState();
}
class _HYHomeContentState extend s State<HYHomeContent> {
// 保存數(shù)據(jù)
final List<MovieItem> movies = [];
@override
void initState() {
super.initState();
// 發(fā)送網(wǎng)絡(luò)請(qǐng)求
HomeRequest.requestMovieList(0).then((res) {
setState(() {
movies.addAll(res);
});
});
}
@override
Widget build(BuildContext context) {
return ListView.builder(
itemCount: movies.length,
itemBuilder: (ctx, index) {
// 渲染數(shù)據(jù)
return HYHomeMovieItem(movies[index]);
}
);
}
}
2.2. 單獨(dú)Item局部
下面是針對(duì)界面結(jié)構(gòu)的分析:

大家按照對(duì)應(yīng)的結(jié)構(gòu),實(shí)現(xiàn)代碼,home_movie_item.dart文件如下:
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:learn_flutter/douban/model/home_model.dart';
import 'package:learn_flutter/douban/utils/log.dart';
import 'package:learn_flutter/douban/widgets/dashed_line.dart';
import 'package:learn_flutter/douban/widgets/star_rating.dart';
class HYHomeMovieItem extends StatelessWidget {
final MovieItem movie;
HYHomeMovieItem(this.movie);
@override
Widget build(BuildContext context) {
return Container(
// 包裹一層Container是為了好設(shè)置內(nèi)邊距
padding: EdgeInsets.all(8),
// 底部加邊框,設(shè)置分隔條
decoration: BoxDecoration(
border:
Border(bottom: BorderSide(width: 8, color: Color(0xffcccccc)))),
child: Column(
// 交叉軸左對(duì)齊
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
// 1. 頭部的布局
buildHeader(),
SizedBox(
height: 8,
),
// 2. 內(nèi)容布局
buildContent(),
SizedBox(
height: 8,
),
// 3. 尾部布局
buildFooter(),
],
),
);
}
// 1.頭部的布局
Widget buildHeader() {
return Container(
padding: EdgeInsets.fromLTRB(10, 5, 10, 5),
decoration: BoxDecoration(
color: Color.fromARGB(255, 238, 205, 144),
borderRadius: BorderRadius.circular(3)),
child: Text(
"No.${movie.rank}",
style: TextStyle(fontSize: 18, color: Color.fromARGB(255, 131, 95, 36)),
),
);
}
// 2.內(nèi)容的布局
Widget buildContent() {
return Row(
// 交叉軸從頭開(kāi)始
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
buildContentImage(),
SizedBox(
width: 8,
),
Expanded(
// 添加IntrinsicHeight組件就能保證內(nèi)容+虛線+想看高度都是一樣的,這樣我們就不需要設(shè)置虛線和想看的高度了
child: IntrinsicHeight(
child: Row(
children: <Widget>[
buildContentInfo(),
SizedBox(
width: 8,
),
// 虛線
buildContentLine(),
SizedBox(
width: 8,
),
// 想看
buildContentWish()
],
),
),
)
],
);
}
// 2.1.內(nèi)容的圖片
Widget buildContentImage() {
return ClipRRect( // 設(shè)置圓角,這種方式簡(jiǎn)單方便
borderRadius: BorderRadius.circular(8),
child: Image.network(
movie.imageURL,
// 設(shè)置高度之后寬度會(huì)自適應(yīng)比例,也就是寬高固定了
height: 150,
));
}
// 2.2.內(nèi)容的信息
Widget buildContentInfo() {
// 因?yàn)樽筮叺膱D片和讓Column都是在一個(gè)row里面,如果文字過(guò)多,文字會(huì)超出屏幕外面
// 所以我們讓Column變成可伸縮的,從而不超出屏幕
return Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
buildContentInfoTitle(),
SizedBox(
height: 8,
),
buildContentInfoRate(),
SizedBox(
height: 8,
),
buildContentInfoDesc()
],
),
);
}
// 2.2.1 內(nèi)容的信息的標(biāo)題
Widget buildContentInfoTitle() {
List<InlineSpan> spans = [];
// 圖標(biāo)+電影名稱+年份,使用row也是可以實(shí)現(xiàn)的,但是以后文字多的時(shí)候row無(wú)法換行,所以使用如下方式
return Text.rich(
TextSpan(children: [
// 以前我們用的是WidgetSpan+textSpan+textSpan,但是這樣會(huì)導(dǎo)致前兩個(gè)不在一條水平線上
// 現(xiàn)在我們?nèi)齻€(gè)都使用WidgetSpan,然后前兩個(gè)設(shè)置PlaceholderAlignment.middle,最后一個(gè)設(shè)置PlaceholderAlignment.bottom
// 就可以讓前兩個(gè)中心點(diǎn)對(duì)齊,最后一個(gè)底部對(duì)齊
// 圖標(biāo)
WidgetSpan(
child: Icon(
Icons.play_circle_outline,
color: Colors.pink,
size: 40,
),
baseline: TextBaseline.ideographic,
alignment: PlaceholderAlignment.middle
),
// 電影名稱
// WidgetSpan要么都是一行顯示,要么就三行顯示,如果電影的名字過(guò)長(zhǎng),就無(wú)法做到兩行顯示的效果
// 我們的解決辦法是讓每個(gè)文字都是一個(gè)WidgetSpan,runes就是每個(gè)文字組成的數(shù)組,使用map映射成WidgetSpan的 Iterable,再轉(zhuǎn)成數(shù)組,再展開(kāi)
...movie.title.runes.map((rune) {
return WidgetSpan(child: Text(new String.fromCharCode(rune), style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),), alignment: PlaceholderAlignment.middle);
}).toList(),
// 年份
WidgetSpan(child: Text("(${movie.playDate})"), style: TextStyle(fontSize: 18, color: Colors.grey), alignment: PlaceholderAlignment.bottom)
])
);
}
// 2.2.2 星級(jí)評(píng)分組件
Widget buildContentInfoRate() {
// 當(dāng)在iPhone5小尺寸手機(jī)上的時(shí)候,左邊圖片的寬度是固定的,右邊的虛線和想看的寬度也是固定的
// 這時(shí)候剩下的寬度也許都不夠星星的寬度了,這時(shí)候就會(huì)報(bào)錯(cuò)
// 我們添加FittedBox,這時(shí)候如果剩下的寬度不夠了,就可以稍微縮小一點(diǎn),這樣就不會(huì)報(bào)錯(cuò)了
return FittedBox(
child: Row(
children: <Widget>[
// 以前封裝的星級(jí)插件☆
HYStarRating(
rating: movie.rating,
size: 20,
),
SizedBox(
width: 6,
),
// 星級(jí)文字
Text(
"${movie.rating}",
style: TextStyle(fontSize: 16),
)
],
),
);
}
// 2.2.3 電影描述
Widget buildContentInfoDesc() {
// 1.字符串拼接
// 數(shù)組元素以空格拼接
final genresString = movie.genres.join(" ");
// 導(dǎo)演
final directorString = movie.director.name;
List<Actor> casts = movie.casts;
// 演員數(shù)組取出名字
final actorString = movie.casts.map((item) => item.name).join(" ");
return Text(
"$genresString / $directorString / $actorString",
maxLines: 2, //最多兩行
overflow: TextOverflow.ellipsis, //超出以后顯示...
style: TextStyle(fontSize: 16),
);
}
// 2.3.內(nèi)容的虛線
Widget buildContentLine() {
return Container(
// height: 100,
child: HYDashedLine(
axis: Axis.vertical,
dashedWidth: .4,
dashedHeight: 6,
count: 10,
color: Colors.pink,
),
);
}
// 2.4.內(nèi)容的想看
Widget buildContentWish() {
return Container(
// height: 100,
child: Column(
// 包裹一個(gè)Container,再設(shè)置為center,讓?和想看垂直居中
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Image.asset("assets/images/home/wish.png"),
Text(
"想看",
style: TextStyle(
fontSize: 18,
color: Color.fromARGB(255, 235, 170, 60)
),
)
],
),
);
}
// 3.尾部的布局
Widget buildFooter() {
return Container(
// 默認(rèn)的寬度是內(nèi)容包裹的寬度,設(shè)置為最大
width: double.infinity,
// 內(nèi)邊距
padding: EdgeInsets.all(8),
// 圓角
decoration: BoxDecoration(
color: Color(0xfff2f2f2),
borderRadius: BorderRadius.circular(6),
),
child: Text(
movie.originalTitle,
style: TextStyle(fontSize: 20, color: Color(0xff666666)),
),
);
}
}
補(bǔ)充:Flutter默認(rèn)的print打印只會(huì)打印信息,并沒(méi)有所在行的信息,所以我們自定義一個(gè)打?。?/p>
void hyLog(Object message, StackTrace current) {
HYCustomTrace programInfo = HYCustomTrace(current);
print("所在文件: ${programInfo.fileName}, 所在行: ${programInfo.lineNumber}, 打印信息: $message");
}
class HYCustomTrace {
final StackTrace _trace;
String fileName;
int lineNumber;
int columnNumber;
HYCustomTrace(this._trace) {
_parseTrace();
}
void _parseTrace() {
var traceString = this._trace.toString().split("\n")[0];
var indexOfFileName = traceString.indexOf(RegExp(r'[A-Za-z_]+.dart'));
var fileInfo = traceString.substring(indexOfFileName);
var listOfInfos = fileInfo.split(":");
this.fileName = listOfInfos[0];
this.lineNumber = int.parse(listOfInfos[1]);
var columnStr = listOfInfos[2];
columnStr = columnStr.replaceFirst(")", "");
this.columnNumber = int.parse(columnStr);
}
}
使用方式如下:
hyLog("aaaaaa", StackTrace.current);