Flutter 使用 Riverpod+Retrofit 構(gòu)建MVVM開發(fā)模式

flutter app

最近,在使用 Flutter 做一個圖片分享的應(yīng)用,自己創(chuàng)建出一套 Flutter 版的 MVVM 開發(fā)模式跨算,覺得還挺好用,所以在此分享出來椭懊。

應(yīng)用功能展示

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

下拉刷新,如圖:

refresh

上拉加載更多坏瘩,如圖:

load more

點贊盅抚,如圖:

liked

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

empty

loading頁倔矾,如圖:

loading

漸變的Appbar妄均,如圖:

appbar

評論,如圖:

comment

我的頁面哪自,如圖:

profile

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

apk

Tip:App 可以使用登錄賬戶信息:
任意一個作者名字壤巷,密碼都是:666666邑彪,如:

用戶名 密碼
persilee 密碼:666666
攝影師蟈蟈小姐 密碼:666666
翠花小拍 密碼:666666

在介紹這套 MVVM 開發(fā)模式之前,我們首先需要了解 riverpodretrofit 是什么胧华。

下面我們來分別了解他們是什么寄症。

riverpod

riverpodFlutter 狀態(tài)管理庫,flutter 的狀態(tài)管理庫有很多矩动,例如: Redux有巧、 BlocProvider 等悲没,flutter 官方推薦我們使用 provider篮迎,一般我們使用 provider 的時候,會結(jié)合 ChangeNotifier 檀训、 StateNotifier柑潦、 freezed 去使用,而 riverpodprovider 的一個升級加強(qiáng)版峻凫,解決了 provider 一些疑難雜癥渗鬼,在這里就不過多介紹梢薪,如想了解更多 riverpod 信息何荚,可以訪問 riverpod官網(wǎng) ,也可以參考我之前寫的以下Demo

retrofit

retrofit 是一個網(wǎng)絡(luò)請求庫晒骇,做過 android 的同學(xué)應(yīng)該比較熟悉,可以用注解的方式生成請求 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

目錄結(jié)構(gòu)

接下來我們來看看項目的目錄結(jié)構(gòu)镐侯,如下:

.
├── android  ## 原生android目錄
│   ├── app
│   └── gradle
├── assets  ## 資源文件目錄
│   ├── fonts
│   ├── images
│   └── json
├── ios ## 原生iOS目錄
│   ├── Flutter
│   ├── Frameworks
│   ├── Pods
│   ├── Runner
│   ├── Runner.xcodeproj
│   └── Runner.xcworkspace
└── lib ## 項目文件目錄
    ├── http ##對網(wǎng)格請求相關(guān)的封裝
    │   ├── api_client.dart ## rest api 請求類
    │   ├── api_client.g.dart ## retrofit 自動生成的類
    │   ├── base_dio.dart ## 對dio封裝類
    │   ├── base_error.dart ## 服務(wù)端基本錯誤類型封裝類
    │   └── header_interceptor.dart  ##網(wǎng)絡(luò)請求攔截器
    ├── 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 ## 處理數(shù)據(jù)狀態(tài)苟翻,業(yè)務(wù)邏輯韵卤,相對于 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

從目錄結(jié)構(gòu)可知, models崇猫、 pages沈条、view_model 分別是 MVVM 開發(fā)模式的 M(數(shù)據(jù)層)、 V(視圖層)诅炉、 VM(通過riverpod的StateNotifier將數(shù)據(jù)層和視圖層綁定蜡歹,state變化時數(shù)據(jù)層也跟著變化,當(dāng)然這里也可以處理一些頁面邏輯)涕烧,做過 android 的同學(xué)應(yīng)該知道 android 的MVVM是使用 jetpack 組件庫里的 DataBinding 和 LiveData 完成的月而,我這套開發(fā)模式靈感就是來源于此。

網(wǎng)絡(luò)請求模塊

首先议纯,我們來對網(wǎng)絡(luò)請求模塊封裝一把景鼠,讓它能夠通用易用。

retrofit 是依賴網(wǎng)絡(luò)請求庫的痹扇,我們可以選擇不同的庫铛漓,例如: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结笨,我們需要對這里重新封裝一下包晰,使用者不用傳遞任何參數(shù)就可以使用,也可以選擇使用不同的網(wǎng)絡(luò)庫和 baseUrl炕吸;所以伐憾,我們要封裝一個 baseDio 單例類,如果用戶沒有傳赫模,我們就傳遞一個默認(rèn)的 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 進(jìn)行一次封裝,代碼如下:

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._(); // 把構(gòu)造方法私有化

  static BaseDio _instance; 

  static BaseDio getInstance() {  // 通過 getInstance 獲取實例
    _instance ??= BaseDio._();

    return _instance;
  }

  Dio getDio() {
    final Dio dio = Dio();
    dio.options = BaseOptions(receiveTimeout: 66000, connectTimeout: 66000); // 設(shè)置超時時間等 ...
    dio.interceptors.add(HeaderInterceptor()); // 添加攔截器瀑罗,如 token之類胸嘴,需要全局使用的參數(shù)
    dio.interceptors.add(PrettyDioLogger(  // 添加日志格式化工具類
      requestHeader: true,
      requestBody: true,
      responseBody: true,
      responseHeader: false,
      compact: false,
    ));

    return dio;
  }

  BaseError getDioError(Object obj) {  // 這里封裝了一個 BaseError 類雏掠,會根據(jù)后端返回的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 類是一個抽象類,我們可以實現(xiàn)這個抽象類劣像,告訴UI不同的錯誤類型乡话,UI只需要用實現(xiàn)類就可以訪問錯誤碼和錯誤消息,代碼如下:

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;

}

網(wǎng)絡(luò)模塊的使用

這樣我們的一個網(wǎng)絡(luò)請求模塊基本就封裝好了绑青,使用起來非常簡單,首先我們需要定義接口屋群,代碼如下:

@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 模塊主要處理數(shù)據(jù)和狀態(tài)的綁定谓晌、業(yè)務(wù)邏輯等。

創(chuàng)建狀態(tài)類

我們首先需要創(chuàng)建一個狀態(tài)類癞揉,來存放數(shù)據(jù)狀態(tài)和頁面狀態(tài)等纸肉,如下:

/// 存儲頁面狀態(tài)和數(shù)據(jù)狀態(tài)(如,缺省頁喊熟、錯誤頁柏肪、加載中...)
class PostState {
  final List<Post> posts;
  final List<Category> categories;
  final int pageIndex;
  final PageState pageState; // 頁面狀態(tài)類
  final BaseError error; // 根據(jù)后端返回的錯誤的錯誤類

  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,
    );
  }
}

當(dāng)然這個狀態(tài)類也可以用 freezed 自動生成。

請求網(wǎng)絡(luò)數(shù)據(jù)和處理頁面狀態(tài)

我們會返回這個狀態(tài)類給UI芥牌,riverpod 的 StateNotifier 會監(jiān)聽這個狀態(tài)類里的所有成員變量烦味,當(dāng)我們更改這些數(shù)據(jù)之后,UI會自動刷新壁拉,代碼如下:

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

以上一個方面就完成了應(yīng)用首頁的所有列表數(shù)據(jù)請求和頁面狀態(tài)處理弃理,在UI層溃论,不需要寫 setState() 和 請求數(shù)據(jù)的任何代碼,UI層只是呈現(xiàn)UI痘昌。

View 模塊

那么在UI層怎么處理這些狀態(tài)呢钥勋?

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

// 創(chuàng)建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 來監(jiān)聽Provider
    final postState = watch(postsProvider(categoryId).state); // 使用 watch 來監(jiān)聽Provider的狀態(tài)
    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 狀態(tài)處理
      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: '暫 無 數(shù) 據(jù)',
        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(  // 加載數(shù)據(jù)算灸,現(xiàn)在頁面
      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() 和 請求數(shù)據(jù)的任何代碼驻啤,代碼結(jié)構(gòu)也非常清晰菲驴。在上述APP應(yīng)用里的首頁以及分類頁面列表數(shù)據(jù)及頁面的loading和缺省頁等都是這一個簡單 PostsPageCategory 完成的。

其他相關(guān)

以上這套開發(fā)模式我給出了大概的思路和部分代碼骑冗,大家也可以順著這個思路試試谢翎;這套開發(fā)模式后續(xù)還會繼續(xù)優(yōu)化它捍靠。

應(yīng)用功能相關(guān)

用過 Flutter TabBar 同學(xué)應(yīng)該知道,它在字體放大時會卡頓森逮,以及如何自定義指示器等榨婆, 如圖:

TabBar

以及,漸變的高斯模糊背景和圖片標(biāo)題動畫的實現(xiàn)等褒侧,如圖:

profile

及更多這個應(yīng)用的功能實現(xiàn)和細(xì)節(jié)并沒有在這里講述良风,這篇文章主要介紹 MVVM,關(guān)于這個圖片分享APP闷供,只是我在業(yè)余時間對Flutter的研究探索和學(xué)習(xí)烟央,這個應(yīng)用大概只完成了一半,后續(xù)應(yīng)該還好寫關(guān)于這個APP的文章歪脏。

REST API接口相關(guān)

還有疑俭,這個APP的后端API也是我自己開發(fā)的,使用的是 nodejs 的 express + ts 開發(fā)的婿失,如首頁推薦接口及分類頁接口數(shù)據(jù)都是通過這個API查詢到的: 首頁API接口

具體的實現(xiàn)是使用一條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,具體的代碼如下(可根據(jù)不同的參數(shù)查詢不同的數(shù)據(jù))豪硅,如下:

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 接口應(yīng)用感興趣的同學(xué)可以參考 寧皓網(wǎng) 的視頻哩照,我就是根據(jù)這套視頻做的,不過自己加了很多東西懒浮。

最后飘弧,很多同學(xué)都希望我開源,所以砚著,給出項目的地址次伶,不過項目還沒有完成,架構(gòu)也在優(yōu)化中...后續(xù)可能項目地址會變化稽穆,目前可以先參考以下地址:
項目地址:https://github.com/persilee/flutter_pro
博客地址:https://lishaoy.net
文章地址:https://h.lishaoy.net/fluttermvvm

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末学少,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子秧骑,更是在濱河造成了極大的恐慌版确,老刑警劉巖,帶你破解...
    沈念sama閱讀 216,372評論 6 498
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件乎折,死亡現(xiàn)場離奇詭異绒疗,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī)骂澄,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,368評論 3 392
  • 文/潘曉璐 我一進(jìn)店門吓蘑,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事磨镶±D瑁” “怎么了?”我有些...
    開封第一講書人閱讀 162,415評論 0 353
  • 文/不壞的土叔 我叫張陵琳猫,是天一觀的道長伟叛。 經(jīng)常有香客問我,道長脐嫂,這世上最難降的妖魔是什么统刮? 我笑而不...
    開封第一講書人閱讀 58,157評論 1 292
  • 正文 為了忘掉前任,我火速辦了婚禮账千,結(jié)果婚禮上侥蒙,老公的妹妹穿的比我還像新娘。我一直安慰自己匀奏,他們只是感情好鞭衩,可當(dāng)我...
    茶點故事閱讀 67,171評論 6 388
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著娃善,像睡著了一般论衍。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上会放,一...
    開封第一講書人閱讀 51,125評論 1 297
  • 那天,我揣著相機(jī)與錄音钉凌,去河邊找鬼咧最。 笑死,一個胖子當(dāng)著我的面吹牛御雕,可吹牛的內(nèi)容都是我干的矢沿。 我是一名探鬼主播,決...
    沈念sama閱讀 40,028評論 3 417
  • 文/蒼蘭香墨 我猛地睜開眼酸纲,長吁一口氣:“原來是場噩夢啊……” “哼捣鲸!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起闽坡,我...
    開封第一講書人閱讀 38,887評論 0 274
  • 序言:老撾萬榮一對情侶失蹤栽惶,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后疾嗅,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體外厂,經(jīng)...
    沈念sama閱讀 45,310評論 1 310
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,533評論 2 332
  • 正文 我和宋清朗相戀三年代承,在試婚紗的時候發(fā)現(xiàn)自己被綠了汁蝶。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 39,690評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖掖棉,靈堂內(nèi)的尸體忽然破棺而出墓律,到底是詐尸還是另有隱情,我是刑警寧澤幔亥,帶...
    沈念sama閱讀 35,411評論 5 343
  • 正文 年R本政府宣布耻讽,位于F島的核電站,受9級特大地震影響紫谷,放射性物質(zhì)發(fā)生泄漏齐饮。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 41,004評論 3 325
  • 文/蒙蒙 一笤昨、第九天 我趴在偏房一處隱蔽的房頂上張望祖驱。 院中可真熱鬧,春花似錦瞒窒、人聲如沸捺僻。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,659評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽匕坯。三九已至,卻和暖如春拔稳,著一層夾襖步出監(jiān)牢的瞬間葛峻,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 32,812評論 1 268
  • 我被黑心中介騙來泰國打工巴比, 沒想到剛下飛機(jī)就差點兒被人妖公主榨干…… 1. 我叫王不留术奖,地道東北人。 一個月前我還...
    沈念sama閱讀 47,693評論 2 368
  • 正文 我出身青樓轻绞,卻偏偏與公主長得像采记,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子政勃,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 44,577評論 2 353

推薦閱讀更多精彩內(nèi)容