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

cwworf-i7dtfztnb-um-ka7nshm.png

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

pf4eecuubmuzynmlpiyioq9tk2e.gif

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

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": 1,
    "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit",
    "body": "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto"
  },
  {
    "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 для сравнения объектов.

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

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

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 posts;
  final bool hasError;
  final bool hasReachedMax;

  PostInitialized({
    this.hasError,
    this.posts,
    this.hasReachedMax,
  }) : super([posts, hasError, hasReachedMax]);

  factory PostInitialized.success(List posts) {
    return PostInitialized(
      posts: posts,
      hasError: false,
      hasReachedMax: false,
    );
  }

  factory PostInitialized.failure() {
    return PostInitialized(
      posts: [],
      hasError: true,
      hasReachedMax: false,
    );
  }

  PostInitialized copyWith({
    List 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 {
  final http.Client httpClient;
  
  PostBloc({@required this.httpClient});
  
  @override
  // TODO: implement initialState
  PostState get initialState => null;

  @override
  Stream 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 {
  final http.Client httpClient;
  
  PostBloc({@required this.httpClient});
  
  @override
  PostState get initialState => PostState.initial();

  @override
  Stream 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 {
  final http.Client httpClient;

  PostBloc({@required this.httpClient});

  @override
  get initialState => PostState.initial();

  @override
  Stream 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> _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 {
  final http.Client httpClient;

  PostBloc({@required this.httpClient});

  @override
  Stream transform(Stream events) {
    return (events as Observable)
        .debounce(Duration(milliseconds: 500));
  }

  @override
  get initialState => PostState.initial();

  @override
  Stream 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> _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 {
  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

© Habrahabr.ru