Хабрахабр

[Перевод] Пример клиент-серверного приложения на Flutter

Примерно вот так В этом туториале мы собираемся разработать приложение, которое получает данные через интернет и отобразим их списком.

Пропишем в командной строке следующее
Окей, начнем с создания проекта.

flutter create flutter_infinite_list

Далее идем в наш файл зависимостей pubspec.yaml и добавляем нужные нам

name: flutter_infinite_list
description: A new Flutter project. version: 1.0.0+1 environment: sdk: ">=2.0.0-dev.68.0 <3.0.0" dependencies: flutter: sdk: flutter flutter_bloc: 0.4.11 http: 0.12.0 equatable: 0.1.1 dev_dependencies: flutter_test: sdk: flutter flutter: uses-material-design: true

После этого устанавливаем эти зависимости следующей командой

flutter packages get

Для этого приложения мы будем использовать jsonplaceholder для получения моковых данных. Если вы не знакомы с этим сервисом, это онлайн REST API сервис, который может отдавать фейковые данные. Это очень полезно для построения прототипов приложения.

Открыв следующую ссылку jsonplaceholder.typicode.com/posts?_start=0&_limit=2 вы увидите JSON ответ, с которым мы будем работать.

[ , { "userId": 1, "id": 2, "title": "qui est esse", "body": "est rerum tempore vitae\nsequi sint nihil reprehenderit dolor beatae ea dolores neque\nfugiat blanditiis voluptate porro vel nihil molestiae ut reiciendis\nqui aperiam non debitis possimus qui neque nisi nulla" }
]

Заметьте, в нашем GET запросе мы указали начальное и конечное ограничение в качестве параметра.

Давайте создадим модель для них. Отлично, теперь мы знаем как будут выглядеть структура наших данные!

Создадим файл post.dart со следующим содержанием

import 'package:equatable/equatable.dart'; class Post extends Equatable { final int id; final String title; final String body; Post({this.id, this.title, this.body}) : super([id, title, body]); @override String toString() => 'Post { id: $id }';
}

Post это только класс с id, title и body. Мы так же можем переопределить функцию toString для отображения удобной строки позднее. В дополнении мы расширяем класс Equatable, таким образом мы можем сравнивать объекты Posts.

Теперь у нас есть модель ответа от сервера, давайте реализовывать бизнес логику (Business Logic Component (bloc)).

Перед тем как мы окунемся в разработку приложения, необходимо определить что наш PostBloc будет делать.

Давайте начнем реализовывать это. На верхнем уровне, он будет отвечать за обработку действий юзера (скроллинг) и получение новых постов, когда слой презентации запросит их.

Получение данных, которое будет показывать на экране по мере необходимости. Наш PostBloc будет отвечать только на один event. Создадим класс post_event.dart и имплементируем наше событие

import 'package:equatable/equatable.dart'; abstract class PostEvent extends Equatable {} class Fetch extends PostEvent { @override String toString() => 'Fetch';
}

Снова переопределим toString для более легкого чтения строки отображающего наш ивент. Так же нам необходимо расширить класс Equatable для сравнения объектов.

Мы разработали все ивенты PostEvents (Fetch), перейдем к PostState. Резюмируя, наш PostBloc будет получать PostEvents и конвертировать их в PostStates.

Наш презентационный слой должен иметь несколько состояний для корректного отображения.

isInitializing — сообщит презентационному слою, что необходимо отобразить индикатор загрузки, пока данные грузятся.

posts — отобразит список объектов Post

isError — сообщит слою, что при загрузке данных произошла ошибок

hasReachedMax — индикация достижения последней доступной записи

Создадим класс post_state.dart со следующим содержанием

import 'package:equatable/equatable.dart'; import 'package:flutter_infinite_list/models/models.dart'; abstract class PostState extends Equatable { PostState([Iterable props]) : super(props);
} class PostUninitialized extends PostState { @override String toString() => 'PostUninitialized';
} class PostInitialized extends PostState { final List<Post> posts; final bool hasError; final bool hasReachedMax; PostInitialized({ this.hasError, this.posts, this.hasReachedMax, }) : super([posts, hasError, hasReachedMax]); factory PostInitialized.success(List<Post> posts) { return PostInitialized( posts: posts, hasError: false, hasReachedMax: false, ); } factory PostInitialized.failure() { return PostInitialized( posts: [], hasError: true, hasReachedMax: false, ); } PostInitialized copyWith({ List<Post> posts, bool hasError, bool hasReachedMax, }) { return PostInitialized( posts: posts ?? this.posts, hasError: hasError ?? this.hasError, hasReachedMax: hasReachedMax ?? this.hasReachedMax, ); } @override String toString() => 'PostInitialized { posts: ${posts.length}, hasError: $hasError, hasReachedMax: $hasReachedMax }';
}

Мы использовали паттерн Factory для удобства и читабельности. Вместо ручного создания сущностей PostState мы можем использовать различные фабрики, например PostState.initial()

Теперь у нас есть ивенты и состояния, пора создать наш PostBloc
Для упрощения наш PostBloc будет иметь прямую зависимость http client, однако в продакшене вам бы следовало завернуть ее во внешнюю зависимость в api client и использовать Repository паттерн.

Создадим post_bloc.dart

import 'package:bloc/bloc.dart';
import 'package:meta/meta.dart';
import 'package:http/http.dart' as http;
import 'package:flutter_infinite_list/bloc/bloc.dart';
import 'package:flutter_infinite_list/models/models.dart'; class PostBloc extends Bloc<PostEvent, PostState> { final http.Client httpClient; PostBloc({@required this.httpClient}); @override // TODO: implement initialState PostState get initialState => null; @override Stream<PostState> mapEventToState( PostState currentState, PostEvent event, ) async* { // TODO: implement mapEventToState yield null; }
}

Заметьте, что только из объявления нашего класса можно сказать, что он будет принимать на вход PostEvents и отдавать PostStates

Перейдем к разработке initialState

import 'package:bloc/bloc.dart';
import 'package:meta/meta.dart';
import 'package:http/http.dart' as http;
import 'package:flutter_infinite_list/bloc/bloc.dart';
import 'package:flutter_infinite_list/models/models.dart'; class PostBloc extends Bloc<PostEvent, PostState> { final http.Client httpClient; PostBloc({@required this.httpClient}); @override PostState get initialState => PostState.initial(); @override Stream<PostState> mapEventToState( PostState currentState, PostEvent event, ) async* { // TODO: implement mapEventToState yield null; }
}

Далее необходимо реализовать mapEventToState, который будет срабатывать каждый раз при отправке события.

import 'dart:convert'; import 'package:meta/meta.dart';
import 'package:http/http.dart' as http;
import 'package:bloc/bloc.dart';
import 'package:flutter_infinite_list/bloc/bloc.dart';
import 'package:flutter_infinite_list/models/models.dart'; class PostBloc extends Bloc<PostEvent, PostState> { final http.Client httpClient; PostBloc({@required this.httpClient}); @override get initialState => PostState.initial(); @override Stream<PostState> mapEventToState(currentState, event) async* { if (event is Fetch && !currentState.hasReachedMax) { try { final posts = await _fetchPosts(currentState.posts.length, 20); if (posts.isEmpty) { yield currentState.copyWith(hasReachedMax: true); } else { yield PostState.success(currentState.posts + posts); } } catch (_) { yield PostState.failure(); } } } Future<List<Post>> _fetchPosts(int startIndex, int limit) async { final response = await httpClient.get( 'https://jsonplaceholder.typicode.com/posts?_start=$startIndex&_limit=$limit'); if (response.statusCode == 200) { final data = json.decode(response.body) as List; return data.map((rawPost) { return Post( id: rawPost['id'], title: rawPost['title'], body: rawPost['body'], ); }).toList(); } else { throw Exception('error fetching posts'); } }
}

Теперь каждый раз PostEvent отправляется, если это событие выборки и мы не достигли конца списка будет отображено следующие 20 записей.

Немного доработаем наш PostBloc

import 'dart:convert'; import 'package:meta/meta.dart';
import 'package:rxdart/rxdart.dart';
import 'package:http/http.dart' as http;
import 'package:bloc/bloc.dart';
import 'package:flutter_infinite_list/bloc/bloc.dart';
import 'package:flutter_infinite_list/models/models.dart'; class PostBloc extends Bloc<PostEvent, PostState> { final http.Client httpClient; PostBloc({@required this.httpClient}); @override Stream<PostEvent> transform(Stream<PostEvent> events) { return (events as Observable<PostEvent>) .debounce(Duration(milliseconds: 500)); } @override get initialState => PostState.initial(); @override Stream<PostState> mapEventToState(currentState, event) async* { if (event is Fetch && !currentState.hasReachedMax) { try { final posts = await _fetchPosts(currentState.posts.length, 20); if (posts.isEmpty) { yield currentState.copyWith(hasReachedMax: true); } else { yield PostState.success(currentState.posts + posts); } } catch (_) { yield PostState.failure(); } } } Future<List<Post>> _fetchPosts(int startIndex, int limit) async { final response = await httpClient.get( 'https://jsonplaceholder.typicode.com/posts?_start=$startIndex&_limit=$limit'); if (response.statusCode == 200) { final data = json.decode(response.body) as List; return data.map((rawPost) { return Post( id: rawPost['id'], title: rawPost['title'], body: rawPost['body'], ); }).toList(); } else { throw Exception('error fetching posts'); } }
}

Отлично, мы завершили реализацию бизнес логики!

Создадим класс main.dart и реализуем в нем runApp для отрисовки нашего UI

import 'package:flutter/material.dart'; void main() { runApp(MyApp());
} class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( title: 'Flutter Infinite Scroll', home: Scaffold( appBar: AppBar( title: Text('Posts'), ), body: HomePage(), ), ); }
}

Далее создадим HomePage, который отобразит наши посты и подключится к PostBloc

class HomePage extends StatefulWidget { @override _HomePageState createState() => _HomePageState();
} class _HomePageState extends State<HomePage> { final _scrollController = ScrollController(); final PostBloc _postBloc = PostBloc(httpClient: http.Client()); final _scrollThreshold = 200.0; _HomePageState() { _scrollController.addListener(_onScroll); _postBloc.dispatch(Fetch()); } @override Widget build(BuildContext context) { return BlocBuilder( bloc: _postBloc, builder: (BuildContext context, PostState state) { if (state.isInitializing) { return Center( child: CircularProgressIndicator(), ); } if (state.isError) { return Center( child: Text('failed to fetch posts'), ); } if (state.posts.isEmpty) { return Center( child: Text('no posts'), ); } return ListView.builder( itemBuilder: (BuildContext context, int index) { return index >= state.posts.length ? BottomLoader() : PostWidget(post: state.posts[index]); }, itemCount: state.hasReachedMax ? state.posts.length : state.posts.length + 1, controller: _scrollController, ); }, ); } @override void dispose() { _postBloc.dispose(); super.dispose(); } void _onScroll() { final maxScroll = _scrollController.position.maxScrollExtent; final currentScroll = _scrollController.position.pixels; if (maxScroll - currentScroll <= _scrollThreshold) { _postBloc.dispatch(Fetch()); } }
}

Далее реализуем BottomLoader, который будет показывать пользователю загрузку новых постов.

class BottomLoader extends StatelessWidget { @override Widget build(BuildContext context) { return Container( alignment: Alignment.center, child: Center( child: SizedBox( width: 33, height: 33, child: CircularProgressIndicator( strokeWidth: 1.5, ), ), ), ); }
}

И наконец, реализуем PostWidget, который будет отрисовывать один объект типа Post

class PostWidget extends StatelessWidget { final Post post; const PostWidget({Key key, @required this.post}) : super(key: key); @override Widget build(BuildContext context) { return ListTile( leading: Text( post.id.toString(), style: TextStyle(fontSize: 10.0), ), title: Text('${post.title}'), isThreeLine: true, subtitle: Text(post.body), dense: true, ); }
}

На этом все, сейчас вы можете запустить приложение и посмотреть результат

Исходники проекта можно скачать на Github

Показать больше

Похожие публикации

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»