Flutter使用Riverpod+Retrofit構建MVVM開發模式

NO IMAGE

最近,在使用 Flutter 做一個圖片分享的應用,自己創建出一套 Flutter 版的 MVVM 開發模式,覺得還挺好用,所以在此分享出來。

應用功能展示

首先,我們來看看我們這套MVVM開發模式,開發出來的應用是個什麼樣子,大概的一部分功能如下:(也可以點擊觀看 演示視頻)

下拉刷新,如圖:

Flutter使用Riverpod+Retrofit構建MVVM開發模式

上拉加載更多,如圖:

Flutter使用Riverpod+Retrofit構建MVVM開發模式

點贊,如圖:

Flutter使用Riverpod+Retrofit構建MVVM開發模式

缺省頁(空數據),如圖:

Flutter使用Riverpod+Retrofit構建MVVM開發模式

loading頁,如圖:

Flutter使用Riverpod+Retrofit構建MVVM開發模式

漸變的Appbar,如圖:

Flutter使用Riverpod+Retrofit構建MVVM開發模式

評論,如圖:

Flutter使用Riverpod+Retrofit構建MVVM開發模式

我的頁面,如圖:

Flutter使用Riverpod+Retrofit構建MVVM開發模式

以上只是 App 的一部分功能,大家也可以點擊觀看 演示視頻,或者掃描二維碼下載 App(android) 體驗:

Flutter使用Riverpod+Retrofit構建MVVM開發模式

在介紹這套 MVVM 開發模式之前,我們首先需要了解 riverpodretrofit 是什麼。

下面我們來分別瞭解他們是什麼。

riverpod

riverpodFlutter 狀態管理庫,flutter 的狀態管理庫有很多,例如: ReduxBlocProvider 等,flutter 官方推薦我們使用 provider,一般我們使用 provider 的時候,會結合 ChangeNotifierStateNotifierfreezed 去使用,而 riverpodprovider 的一個升級加強版,解決了 provider 一些疑難雜症,在這裡就不過多介紹,如想了解更多 riverpod 信息,可以訪問 riverpod官網 ,也可以參考我之前寫的以下Demo

retrofit

retrofit 是一個網絡請求庫,做過 android 的同學應該比較熟悉,可以用註解的方式生成請求 Rest Api 的各種方法,如,以下的簡單的用法:

import 'package:retrofit/retrofit.dart';
part 'api_client.g.dart';
@RestApi(baseUrl: 'https://api.lishaoy.net')
abstract class ApiClient {
factory ApiClient({Dio dio, String baseUrl}) {
dio ??= BaseDio.getInstance().getDio();
return _ApiClient(dio, baseUrl: baseUrl);
}
/**
* 獲取首頁推薦文章
*/
@GET('/posts')
Future<PostModel> getPosts(
@Query('pageIndex') String pageIndex, @Query('pageSize') String pageSize,
{@Query('sort') String sort = 'recommend'});
/**
* 獲取文章詳情
*/
@GET('/posts/{postId}')
Future<SinglePostModel> getPostsById(@Path('postId') int postId,
{@Query('notView') bool notView});
/**
* 登錄
*/
@POST('/login')
Future<LoginModel> login(@Body() Login login);
}

更多詳情可以訪問 pub.dev retrofit

目錄結構

接下來我們來看看項目的目錄結構,如下:

.
├── android  ## 原生android目錄
│   ├── app
│   └── gradle
├── assets  ## 資源文件目錄
│   ├── fonts
│   ├── images
│   └── json
├── ios ## 原生iOS目錄
│   ├── Flutter
│   ├── Frameworks
│   ├── Pods
│   ├── Runner
│   ├── Runner.xcodeproj
│   └── Runner.xcworkspace
└── lib ## 項目文件目錄
├── http ##對網格請求相關的封裝
│   ├── api_client.dart ## rest api 請求類
│   ├── api_client.g.dart ## retrofit 自動生成的類
│   ├── base_dio.dart ## 對dio封裝類
│   ├── base_error.dart ## 服務端基本錯誤類型封裝類
│   └── header_interceptor.dart  ##網絡請求攔截器
├── models ## json序列化的model類,相對於MVVM的 M 層
├── pages ## 主要的UI頁面目錄,相對於MVVM的 V 層
├── utils ## 一些工具類
│   ├── date_util.dart
│   ├── screen_util.dart
│   ├── status_bar_util.dart
│   ├── timeline_util.dart
│   └── widget_util.dart
├── view_model ## 處理數據狀態,業務邏輯,相對於 MVVM的 VM 層
│   ├── details_view_model.dart
│   ├── login_view_model.dart
│   ├── posts_view_model.dart
│   └── profile_view_model.dart
└── widgets ##公用或自定義組件
├── cache_image.dart
├── custom_circular_rect_angle.dart
├── custom_indicator.dart
├── custom_tabs.dart
├── error_page.dart
├── gradient_button.dart
├── icon_animation_widget.dart
├── iconfont.dart
├── image_paper.dart
├── over_scroll_behavior.dart
├── page_state.dart
├── per_flexible_space_bar.dart
├── pic_swiper.dart
└── refresh.dart

從目錄結構可知, modelspagesview_model 分別是 MVVM 開發模式的 M(數據層)、 V(視圖層)、 VM(通過riverpod的StateNotifier將數據層和視圖層綁定,state變化時數據層也跟著變化,當然這裡也可以處理一些頁面邏輯),做過 android 的同學應該知道 android 的MVVM是使用 jetpack 組件庫裡的 DataBinding 和 LiveData 完成的,我這套開發模式靈感就是來源於此。

網絡請求模塊

首先,我們來對網絡請求模塊封裝一把,讓它能夠通用易用。

retrofit 是依賴網絡請求庫的,我們可以選擇不同的庫,例如:httpDio 等。

在這裡我們選擇 Dio ,如下,是官方提供的案例代碼:

@RestApi(baseUrl: "https://5d42a6e2bc64f90014a56ca0.mockapi.io/api/v1/")
abstract class RestClient {
factory RestClient(Dio dio, {String baseUrl}) = _RestClient;
@GET("/tasks")
Future<List<Task>> getTasks();
}

Dio的封裝

它需要傳一個 Dio 的實例和一個可選的 baseUrl,我們需要對這裡重新封裝一下,使用者不用傳遞任何參數就可以使用,也可以選擇使用不同的網絡庫和 baseUrl;所以,我們要封裝一個 baseDio 單例類,如果用戶沒有傳,我們就傳遞一個默認的 baseDio 類,代碼大概如下所示:

@RestApi(baseUrl: 'https://api.lishaoy.net')
abstract class ApiClient {
factory ApiClient({Dio dio, String baseUrl}) {
dio ??= BaseDio.getInstance().getDio();
return _ApiClient(dio, baseUrl: baseUrl);
@POST('/login')
Future<LoginModel> login(@Body() Login login);
}  

所以我要對 Dio 進行一次封裝,代碼如下:

import 'package:dio/dio.dart';
import 'package:pretty_dio_logger/pretty_dio_logger.dart';
import 'package:pro_flutter/http/base_error.dart';
import 'package:pro_flutter/http/header_interceptor.dart';
class BaseDio {
BaseDio._(); // 把構造方法私有化
static BaseDio _instance; 
static BaseDio getInstance() {  // 通過 getInstance 獲取實例
_instance ??= BaseDio._();
return _instance;
}
Dio getDio() {
final Dio dio = Dio();
dio.options = BaseOptions(receiveTimeout: 66000, connectTimeout: 66000); // 設置超時時間等 ...
dio.interceptors.add(HeaderInterceptor()); // 添加攔截器,如 token之類,需要全局使用的參數
dio.interceptors.add(PrettyDioLogger(  // 添加日誌格式化工具類
requestHeader: true,
requestBody: true,
responseBody: true,
responseHeader: false,
compact: false,
));
return dio;
}
BaseError getDioError(Object obj) {  // 這裡封裝了一個 BaseError 類,會根據後端返回的code返回不同的錯誤類
switch (obj.runtimeType) {
case DioError:
if ((obj as DioError).type == DioErrorType.RESPONSE) {
final response = (obj as DioError).response;
if (response.statusCode == 401) {
return NeedLogin();
} else if (response.statusCode == 403) {
return NeedAuth();
} else if (response.statusCode == 408) {
return UserNotExist();
} else if (response.statusCode == 409) {
return PwdNotMatch();
} else if (response.statusCode == 405) {
return UserNameEmpty();
} else if (response.statusCode == 406) {
return PwdEmpty();
} else {
return OtherError(
statusCode: response.statusCode,
statusMessage: response.statusMessage,
);
}
}
}
return OtherError();
}
}

BaseError的封裝

以上代碼中的 BaseError 類是一個抽象類,我們可以實現這個抽象類,告訴UI不同的錯誤類型,UI只需要用實現類就可以訪問錯誤碼和錯誤消息,代碼如下:

abstract class BaseError {
final int code;
final String message;
BaseError({this.code, this.message});
}
class NeedLogin implements BaseError {
@override
int get code => 401;
@override
String get message => "請先登錄";
}
class NeedAuth implements BaseError {
@override
int get code => 403;
@override
String get message => "非法訪問,請使用正確的token";
}
class UserNotExist implements BaseError {
@override
int get code => 408;
@override
String get message => "用戶不存在";
}
class UserNameEmpty implements BaseError {
@override
int get code => 405;
@override
String get message => "用戶名不能為空";
}
class PwdNotMatch implements BaseError {
@override
int get code => 409;
@override
String get message => "用戶密碼不正確";
}
class PwdEmpty implements BaseError {
@override
int get code => 406;
@override
String get message => "用戶密碼不能為空";
}
class OtherError implements BaseError {
final int statusCode;
final String statusMessage;
OtherError({this.statusCode, this.statusMessage});
@override
int get code => statusCode;
@override
String get message => statusMessage;
}

網絡模塊的使用

這樣我們的一個網絡請求模塊基本就封裝好了,使用起來非常簡單,首先我們需要定義接口,代碼如下:

@RestApi(baseUrl: 'https://api.lishaoy.net')
abstract class ApiClient {
factory ApiClient({Dio dio, String baseUrl}) {
dio ??= BaseDio.getInstance().getDio();
return _ApiClient(dio, baseUrl: baseUrl);
}
/**
* 獲取首頁推薦文章
*/
@GET('/posts')
Future<PostModel> getPosts(
@Query('pageIndex') String pageIndex, @Query('pageSize') String pageSize,
{@Query('sort') String sort = 'recommend'});
/**
* 獲取文章詳情
*/
@GET('/posts/{postId}')
Future<SinglePostModel> getPostsById(@Path('postId') int postId,
{@Query('notView') bool notView});
/**
* 登錄
*/
@POST('/login')
Future<LoginModel> login(@Body() Login login);
/**
* 點贊
*/
@POST('/posts/{postId}/like')
Future<BaseModel> like(@Path('postId') int postId);
...

然後,我們會在 view model 使用它,如下:

  /**
* 點贊
*/
Future<void> clickLike(int postId, int index) async {
try {
BaseModel data = await ApiClient().like(postId); // 使用非常簡單一句代碼即可
if (data.message == 'success') {
updatePostById(postId, index);
}
} catch (e) {
state = state.copyWith(
pageState: PageState.errorState,
error: BaseDio.getInstance().getDioError(e));
}
}

View Model 模塊

View Model 模塊主要處理數據和狀態的綁定、業務邏輯等。

創建狀態類

我們首先需要創建一個狀態類,來存放數據狀態和頁面狀態等,如下:

/// 存儲頁面狀態和數據狀態(如,缺省頁、錯誤頁、加載中...)
class PostState {
final List<Post> posts;
final List<Category> categories;
final int pageIndex;
final PageState pageState; // 頁面狀態類
final BaseError error; // 根據後端返回的錯誤的錯誤類
PostState(
{this.posts,
this.categories,
this.pageIndex,
this.pageState,
this.error});
PostState.initial()
: posts = [],
categories = [],
pageIndex = 1,
pageState = PageState.initializedState,
error = null;
PostState copyWith({
List<Post> posts,
List<Category> categories,
int pageIndex,
PageState pageState,
BaseError error,
}) {
return PostState(
posts: posts ?? this.posts,
categories: categories ?? this.categories,
pageIndex: pageIndex ?? this.pageIndex,
pageState: pageState ?? this.pageState,
error: error ?? this.error,
);
}
}

當然這個狀態類也可以用 freezed 自動生成。

請求網絡數據和處理頁面狀態

我們會返回這個狀態類給UI,riverpod 的 StateNotifier 會監聽這個狀態類裡的所有成員變量,當我們更改這些數據之後,UI會自動刷新,代碼如下:

/**
* 獲取文章列表
*/
Future<void> getPosts(int categoryId, {bool isRefresh = false}) async {
if (state.pageState == PageState.initializedState) {
state = state.copyWith(pageState: PageState.busyState); // UI收到這個狀態可以呈現loading頁面
}
try {
if (isRefresh) {  // 下拉刷新
PostModel postModel;
if(categoryId == -2) {
state = state.copyWith(pageState: PageState.emptyDataState); // UI收到這個狀態,可以顯示缺省頁空數據
return;
} else if (categoryId == -1) {
postModel = await ApiClient().getPosts('1', '10'); // 請求網絡接口
} else {
postModel =
await ApiClient().getPostsByCategoryId('1', '10', categoryId);
}
if (postModel.data.posts.isEmpty && state.pageIndex == 1) {
state = state.copyWith(pageState: PageState.emptyDataState);
} else {
initPostState();
state = state.copyWith(
posts: [...postModel.data.posts],  // 把數據發給UI
pageState: PageState.refreshState, // 更改頁面狀態為刷新
pageIndex: 2,
);
}
} else {  // 下拉加載更多
PostModel postModel;
if(categoryId == -2) {
state = state.copyWith(pageState: PageState.emptyDataState); // UI收到這個狀態可以呈現loading頁面
return;
} else if (categoryId == -1) {
postModel =
await ApiClient().getPosts(state.pageIndex.toString(), '10'); // 請求網絡接口
} else {
postModel = await ApiClient().getPostsByCategoryId(
state.pageIndex.toString(), '10', categoryId);
}
if (postModel.data.posts.isEmpty && state.pageIndex == 1) {
state = state.copyWith(pageState: PageState.emptyDataState);
} else {
state = state.copyWith(
posts: [...state.posts, ...postModel.data.posts],  // 把數據發給UI
pageIndex: state.pageIndex + 1,
pageState: PageState.dataFetchState); // 更改頁面狀態
if (postModel.data.posts.isEmpty ||
postModel.data.posts.length < 10) {
state = state.copyWith(pageState: PageState.noMoreDataState);
}
}
}
} catch (e) {
state = state.copyWith(
pageState: PageState.errorState,  // 如果發生錯誤,更改頁面狀態
error: BaseDio.getInstance().getDioError(e));
}
}

以上一個方面就完成了應用首頁的所有列表數據請求和頁面狀態處理,在UI層,不需要寫 setState() 和 請求數據的任何代碼,UI層只是呈現UI。

View 模塊

那麼在UI層怎麼處理這些狀態呢?

這也非常簡單,代碼如下:

// 創建provider,返回viewModel
final postsProvider = StateNotifierProvider.family<PostsViewModel, int>(
(ref, categoryId) => PostsViewModel(categoryId));
class PostsPageCategory extends ConsumerWidget {  // 繼承 ConsumerWidget
final int categoryId;
final ScrollController scrollController;
final RefreshController refreshController;
PostsPageCategory(
{this.categoryId, this.scrollController, this.refreshController});
@override
Widget build(BuildContext context, ScopedReader watch) { 
final postsViewModel = watch(postsProvider(categoryId)); // 使用 watch 來監聽Provider
final postState = watch(postsProvider(categoryId).state); // 使用 watch 來監聽Provider的狀態
return Refresh(
controller: refreshController,
onLoading: () async {  // 加載更多處理
await postsViewModel.getPosts(categoryId);
if (postState.pageState == PageState.noMoreDataState) {
refreshController.loadNoData();
} else {
refreshController.loadComplete();
}
},
onRefresh: () async { // 刷新處理
await context
.read(postsProvider(categoryId))
.getPosts(categoryId, isRefresh: true);
refreshController.refreshCompleted();
refreshController.footerMode.value = LoadStatus.canLoading;
},
content: _createContent(postState, context),
);
}
Widget _createContent(PostState postState, BuildContext context) {
if (postState.pageState == PageState.busyState ||
postState.pageState == PageState.initializedState) {  // loading 狀態處理
return Center(
child: Lottie.asset(
'assets/json/loading2.json',
width: 126,
fit: BoxFit.cover,
alignment: Alignment.center,
),
);
}
if (postState.pageState == PageState.emptyDataState) {
return ErrorPage( // 錯誤處理
isEmptyPage: true,
icon: Lottie.asset(
'assets/json/empty3.json',
width: ScreenUtil.instance.width / 1.8,
height: 220,
fit: BoxFit.contain,
alignment: Alignment.center,
),
desc: '暫 無 數 據',
buttonAction: () => context.refresh(postsProvider(categoryId)),
);
}
if (postState.pageState == PageState.errorState) {
return ErrorPage(
title: postState.error is NeedLogin
? '😮 你竟然忘記登錄 😮'
: postState.error.code?.toString(),
desc: postState.error.message,
buttonAction: () async {
if (postState.error is NeedLogin) {
LoginState loginState = await Navigator.of(context).push(
MaterialPageRoute(builder: (context) => FlareSignInDemo()));
if (loginState.isLogin) {
context.refresh(postsProvider(categoryId));
}
} else {
context.refresh(postsProvider(categoryId));
}
},
buttonText: postState.error is NeedLogin ? '登錄' : null,
);
}
return ListView.separated(  // 加載數據,現在頁面
shrinkWrap: true,
separatorBuilder: (context, index) {
return Padding(padding: EdgeInsets.only(top: 12));
},
padding: EdgeInsets.fromLTRB(12, 18, 12, 18),
reverse: false,
itemCount: postState.posts.length,
controller: scrollController,
itemBuilder: (BuildContext context, int index) {
return PostsPageItem(
post: postState.posts[index],
index: index,
categoryId: categoryId,
);
},
);
}
}

是不是非常簡單,不需要寫 setState() 和 請求數據的任何代碼,代碼結構也非常清晰。在上述APP應用裡的首頁以及分類頁面列表數據及頁面的loading和缺省頁等都是這一個簡單 PostsPageCategory 完成的。

其他相關

以上這套開發模式我給出了大概的思路和部分代碼,大家也可以順著這個思路試試;這套開發模式後續還會繼續優化它。

應用功能相關

用過 Flutter TabBar 同學應該知道,它在字體放大時會卡頓,以及如何自定義指示器等, 如圖:

Flutter使用Riverpod+Retrofit構建MVVM開發模式

以及,漸變的高斯模糊背景和圖片標題動畫的實現等,如圖:

Flutter使用Riverpod+Retrofit構建MVVM開發模式

及更多這個應用的功能實現和細節並沒有在這裡講述,這篇文章主要介紹 MVVM,關於這個圖片分享APP,只是我在業餘時間對Flutter的研究探索和學習,這個應用大概只完成了一半,後續應該還好寫關於這個APP的文章。

REST API接口相關

還有,這個APP的後端API也是我自己開發的,使用的是 nodejs 的 express + ts 開發的,如首頁推薦接口及分類頁接口數據都是通過這個API查詢到的: 首頁API接口

具體的實現是使用一條SQL語句查詢得到,代碼如下:

    SELECT 
post.id, 
post.content, 
post.title,
category.name as category,
post.views,
JSON_OBJECT(
'id', user.id,
'name', user.name,
'avatar', CAST(
IF(COUNT(avatar.id), 
GROUP_CONCAT(
DISTINCT JSON_OBJECT(
'largeAvatarUrl', concat('http://localhost:3001/avatar/', user.id, '|@u003f|size=large'),
'mediumAvatarUrl', concat('http://localhost:3001/avatar/', user.id, '|@u003f|size=medium'),
'smallAvatarUrl', concat('http://localhost:3001/avatar/', user.id, '|@u003f|size=small')
)
),
NULL)
AS JSON)
) as user,
(
SELECT COUNT(comment.id) FROM comment
WHERE comment.postId = post.id
GROUP BY comment.postId
) as totalComments,   
CAST(
IF(
COUNT(cover.id),
GROUP_CONCAT(
DISTINCT JSON_OBJECT(
'id', cover.id,
'width', cover.width,
'height', cover.height,
'largeImageUrl', concat('http://localhost:3001/files/', cover.id, '/serve|@u003f|size=large'),
'mediumImageUrl', concat('http://localhost:3001/files/', cover.id, '/serve|@u003f|size=medium'),
'small', concat('http://localhost:3001/files/', cover.id, '/serve|@u003f|size=thumbnail')
) ORDER BY cover.id DESC
),
NULL
) AS JSON
) AS coverImage,
CAST(
IF(
COUNT(file.id),
CONCAT(
'[',
GROUP_CONCAT(
DISTINCT JSON_OBJECT(
'id', file.id,
'width', file.width,
'height', file.height,
'largeImageUrl', concat('http://localhost:3001/files/', file.id, '/serve|@u003f|size=large'),
'mediumImageUrl', concat('http://localhost:3001/files/', file.id, '/serve|@u003f|size=medium'),
'small', concat('http://localhost:3001/files/', file.id, '/serve|@u003f|size=thumbnail')
) ORDER BY file.id DESC
),
']'
),
NULL
) AS JSON
) AS files,
CAST(
IF(
COUNT(tag.id),
CONCAT(
'[', 
GROUP_CONCAT(
DISTINCT JSON_OBJECT(
'id', tag.id,
'name', tag.name
)
),
']'
),
NULL
) AS JSON
) AS tags,
(
SELECT COUNT(user_like_post.postId)
FROM user_like_post
WHERE user_like_post.postId = post.id
) AS totalLikes
FROM post 
LEFT JOIN user 
ON user.id = post.userId
LEFT JOIN avatar
ON avatar.userId = user.id
LEFT JOIN LATERAL (
SELECT * FROM file
WHERE file.postId = post.id
ORDER BY file.id DESC
LIMIT 9
) AS file ON file.postId = post.id
LEFT JOIN LATERAL(
SELECT * FROM file
WHERE file.isCover = 1 AND file.postId = post.id
GROUP BY file.id
LIMIT 1
) AS cover ON cover.postId = post.id and cover.isCover = 1 
LEFT JOIN post_tag
ON post_tag.postId = post.id
LEFT JOIN tag
ON tag.id = post_tag.tagId
LEFT JOIN category 
ON post.categoryId = category.id
WHERE post.id IS NOT NULL
GROUP BY post.id
ORDER BY post.id DESC
LIMIT 10
OFFSET 0

這個是打印出來的log,具體的代碼如下(可根據不同的參數查詢不同的數據),如下:

export const getPosts = async (options: GetPostOptions) => {
const {
sort,
filter,
pagination: { limit, offset },
userId,
} = options;
let params: Array<any> = [limit, offset];
if (filter.param) {
params = [filter.param, ...params];
}
if (userId) {
params = [userId, ...params];
}
console.log(`params: ${params}`);
const sql = `
SELECT 
post.id, 
post.content, 
post.title,
category.name as category,
post.views,
post.createdAt,
post.updatedAt,
${sqlFragment.user},
${sqlFragment.totalComments},
${sqlFragment.coverImage},
${sqlFragment.file},
${sqlFragment.tags}
${userId ? `, ${sqlFragment.liked} ` : ''},
${sqlFragment.totalLikes}
FROM post 
${sqlFragment.leftJoinUser}
${sqlFragment.leftJoinOneFile}
${sqlFragment.leftJoinCover}
${sqlFragment.leftJoinTag}
${sqlFragment.leftJoinCategory}
${filter.name == 'userLiked' ? sqlFragment.innerJoinUserLikePost : ''}
WHERE ${filter.sql}
GROUP BY post.id
ORDER BY ${sort}
LIMIT ?
OFFSET ?
`;
console.log(sql);
const [data] = await connection.promise().query(sql, params);
return data;
};

如果這個後端 REST API 接口應用感興趣的同學可以參考 寧皓網 的視頻,我就是根據這套視頻做的,不過自己加了很多東西。

最後附上我的博客地址:
博客地址:lishaoy.net
文章地址:h.lishaoy.net/fluttermvvm

相關文章

成為優秀程序員必備的6個提高條件判斷語句可讀性的方式

自定義View實現字母導航控件

Nginx——負載均衡、動靜分離介紹

摸魚小技巧之IDEA調試篇一