Наконец-то мы добрались до одной из самых важных тем, без которой идти дальше нет смысла.
План довольно простой: нам предстоит познакомиться с клиент-серверной архитектурой и реализовать получение списка постов.
В конце мы правильно организуем файлы наших страниц и вынесем элемент списка в отдельный файл.
Полетели!
Наш план
Часть 1 - введение в разработку, первое приложение, понятие состояния;
Часть 2 - файл pubspec.yaml и использование flutter в командной строке;
Часть 3 - BottomNavigationBar и Navigator;
Часть 4 - MVC. Мы будем использовать именно этот паттерн, как один из самых простых;
Часть 5 (текущая статья) - http пакет. Создание Repository класса, первые запросы, вывод списка постов;
Часть 6 - работа с формами, текстовые поля и создание поста.
Часть 7 - работа с картинками, вывод картинок в виде сетки, получение картинок из сети, добавление своих в приложение;
Часть 8 - создание своей темы, добавление кастомных шрифтов и анимации;
Часть 9 - немного о тестировании;
Client и Server
Модель Client / Server лежит в основе всего Интернета и является наиболее распространенной.
В чем её суть?
Сначала разберемся что такое клиент и сервер:
Клиент - пользовательское устройство, которое отправляет запросы за сервер и получает ответы. Это может быть смартфон, компьютер или MacBook.
Сервер - специальный компьютер, который содержит данные, необходимые для пользователя.
Вся модель сводиться к примитивному принципу: клиент отправил запрос, сервер принял его, обработал и передал ответ клиенту.
Для организации взаимодействия сервера и клиента используются специальные протоколы. На текущий момент одним из самых распространенных протоколов в сети Интернет является http / https (s означает защищенный, secure).
http / https позволяет передавать почти все известные форматы данных: картинки, видео, текст.
Мы будем работать с JSON форматом.
JSON - простой и понятный формат данных, а главное легковесный, т.к. передается только текст.
Пример 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"
},
...
]
Здесь массив постов, который мы будем получать от сервера.
Обратите внимание: квадратные скобки указывает на массив данных, а фигурные на отдельный объект.
JSON позволяет создавать глубокую вложенность объектов и массивов:
{
"total_items" : 1
"result" : [
{
"id" : 1,
"name" : "Twillight Sparkle",
"pony_type" : "alicorn",
"friends" : [
"Starlight Glimmer", "Applejack", "Rarity", "Spike"
]
}
]
}
Понятие запроса
Для обмена данными клиент должен отправлять запросы на сервер.
Т.к. интернет в большинстве случаев использует http / https то запросы называются HTTP запросами.
Структура HTTP запроса:
URL - уникальный адрес в Интернете, который идентифицирует сервер и его конкретный ресурс, данные которого мы собираемся получить. В нашем случае URL выглядит следующим образом: https://jsonplaceholder.typicode.com/posts. (об структуре самого URL'а можно почитать в Википедии)
Метод, который определяет типа запроса. GET используется только для получения данных, POST позволяет клиенту добавить свои данные на сервер, DELETE - удалить их, PUT - изменить.
Данные запроса обычно называются телом запроса и используются совместно с POST, PUT и DELETE методами. Для GET метода в основном используются параметры самого URL'а. Выглядит это следующим образом: https://jsonplaceholder.typicode.com/posts/1 (здесь мы обращаемся к конкретному посту по его id = 1)
Запрос и вывод списка постов
Мы будем использовать довольно мощный и простой пакет http
для отправки запросов на сервер.
Сначала убедимся, что мы указали его в pubspec.yaml
файле:
# блок зависимостей
dependencies:
flutter:
sdk: flutter
# подключение необходимых pub-пакетов
# используется для произвольного размещения
# компонентов в виде сетки
flutter_staggered_grid_view: ^0.4.0
# мы будем использовать MVC паттерн
mvc_pattern: ^7.0.0
# http предоставляет удобный интерфейс для создания
# запросов и обработки ошибок
http: ^0.13.3
Переходим к созданию классов модели.
Для этого создайте файл post.dart
в папке models
:
// сначала создаем объект самого поста
class Post {
// все поля являются private
// это сделано для инкапсуляции данных
final int _userId;
final int _id;
final String _title;
final String _body;
// создаем getters для наших полей
// дабы только мы могли читать их
int get userId => _userId;
int get id => _id;
String get title => _title;
String get body => _body;
// Dart позволяет создавать конструкторы с разными именами
// В данном случае Post.fromJson(json) - это конструктор
// здесь мы принимаем JSON объект поста и извлекаем его поля
// обратите внимание, что dynamic переменная
// может иметь разные типы: String, int, double и т.д.
Post.fromJson(Map<String, dynamic> json) :
this._userId = json["userId"],
this._id = json["id"],
this._title = json["title"],
this._body = json["body"];
}
// PostList являются оберткой для массива постов
class PostList {
final List<Post> posts = [];
PostList.fromJson(List<dynamic> jsonItems) {
for (var jsonItem in jsonItems) {
posts.add(Post.fromJson(jsonItem));
}
}
}
// наше представление будет получать объекты
// этого класса и определять конкретный его
// подтип
abstract class PostResult {}
// указывает на успешный запрос
class PostResultSuccess extends PostResult {
final PostList postList;
PostResultSuccess(this.postList);
}
// произошла ошибка
class PostResultFailure extends PostResult {
final String error;
PostResultFailure(this.error);
}
// загрузка данных
class PostResultLoading extends PostResult {
PostResultLoading();
}
Одной из наиболее неприятных проблем является несоответствие типов.
Если взглянуть на 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
и id
являются целыми числами, а title
и body
строками, поэтому в конструкторе Post.fromJson(json)
мы не замарачиваемся с привидением типов.
Пришло время создать Repository
класс.
Для этого создадим новую папку data
и в нем файл repository.dart
:
import 'dart:convert';
// импортируем http пакет
import 'package:http/http.dart' as http;
import 'package:json_placeholder_app/models/post.dart';
// мы ещё не раз будем использовать
// константу SERVER
const String SERVER = "https://jsonplaceholder.typicode.com";
class Repository {
// обработку ошибок мы сделаем в контроллере
// мы возвращаем Future объект, потому что
// fetchPhotos асинхронная функция
// асинхронные функции не блокируют UI
Future<PostList> fetchPosts() async {
// сначала создаем URL, по которому
// мы будем делать запрос
final url = Uri.parse("$SERVER/posts");
// делаем GET запрос
final response = await http.get(url);
// проверяем статус ответа
if (response.statusCode == 200) {
// если все ок то возвращаем посты
// json.decode парсит ответ
return PostList.fromJson(json.decode(response.body));
} else {
// в противном случае говорим об ошибке
throw Exception("failed request");
}
}
}
Вы скажите: мы могли все запихнуть в контроллер, зачем создавать ещё один класс?
Т.к. контроллеров может быть огромное количество и каждый из них будет обращаться к одному и тому же серверу, нам придеться дублировать логику.
К тому же это не очень гибко. Вдруг нам нужно будет поменять URL адрес сервера.
Реализуем PostController
:
import '../data/repository.dart';
import '../models/post.dart';
import 'package:mvc_pattern/mvc_pattern.dart';
class PostController extends ControllerMVC {
// создаем наш репозиторий
final Repository repo = new Repository();
// конструктор нашего контроллера
PostController();
// первоначальное состояние - загрузка данных
PostResult currentState = PostResultLoading();
void init() async {
try {
// получаем данные из репозитория
final postList = await repo.fetchPosts();
// если все ок то обновляем состояние на успешное
setState(() => currentState = PostResultSuccess(postList));
} catch (error) {
// в противном случае произошла ошибка
setState(() => currentState = PostResultFailure("Нет интернета"));
}
}
}
Заключительная часть: подключим наш контроллер к представлению и выведем посты:
import 'package:flutter/material.dart';
import '../controllers/post_controller.dart';
import '../models/post.dart';
import 'package:mvc_pattern/mvc_pattern.dart';
class PostListPage extends StatefulWidget {
@override
_PostListPageState createState() => _PostListPageState();
}
// не забываем расширяться от StateMVC
class _PostListPageState extends StateMVC {
// ссылка на наш контроллер
PostController _controller;
// передаем наш контроллер StateMVC конструктору и
// получаем на него ссылку
_PostListPageState() : super(PostController()) {
_controller = controller as PostController;
}
// после инициализации состояния
// мы запрашивает данные у сервера
@override
void initState() {
super.initState();
_controller.init();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("Post List Page"),
),
body: _buildContent()
);
}
Widget _buildContent() {
// первым делом получаем текущее состояние
final state = _controller.currentState;
if (state is PostResultLoading) {
// загрузка
return Center(
child: CircularProgressIndicator(),
);
} else if (state is PostResultFailure) {
// ошибка
return Center(
child: Text(
state.error,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headline4.copyWith(color: Colors.red)
),
);
} else {
// отображаем список постов
final posts = (state as PostResultSuccess).postList.posts;
return Padding(
padding: EdgeInsets.all(10),
// ListView.builder создает элемент списка
// только когда он видим на экране
child: ListView.builder(
itemCount: posts.length,
itemBuilder: (context, index) {
return _buildPostItem(posts[index]);
},
),
);
}
}
// элемент списка
Widget _buildPostItem(Post post) {
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(15)),
border: Border.all(color: Colors.grey.withOpacity(0.5), width: 0.3)
),
margin: EdgeInsets.only(bottom: 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.only(topLeft: Radius.circular(15), topRight: Radius.circular(15)),
color: Theme.of(context).primaryColor,
),
padding: EdgeInsets.all(10),
child: Text(
post.title,
textAlign: TextAlign.left,
style: Theme.of(context).textTheme.headline5.copyWith(color: Colors.white),),
),
Container(
child: Text(
post.body,
style: Theme.of(context).textTheme.bodyText2,
),
padding: EdgeInsets.all(10),
),
],
)
);
}
}
Не пугайтесь если слишком много кода.
Все сразу освоить невозможно, поэтому не спешите)
Запуск
Попробуем запустить:
Вуаля! Теперь отключим интернет:
Все работает!
Небольшая заметка
Одним из важных принципов программирования является стремление к минимизации кода и его упрощению.
Файл post_list_page.dart
содержит всего 110 строк кода, это не проблема. Но если бы он был в 10 или даже в 20 раз больше!
Какой ужас был бы на глазах у того, кто взглянул бы на него.
Лучшей практикой считается выносить повторяющие фрагменты кода в отдельные файлы.
Давайте попробуем вынести функцию Widget _buildItem(post)
в другой файл.
Для этого создадим для каждой группы страниц свою папку:
Затем в папке post создадим новый файл post_list_item.dart:
import 'package:flutter/material.dart';
import '../../models/post.dart';
// элемент списка
class PostListItem extends StatelessWidget {
final Post post;
// элемент списка отображает один пост
PostListItem(this.post);
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(15)),
border: Border.all(color: Colors.grey.withOpacity(0.5), width: 0.3)
),
margin: EdgeInsets.only(bottom: 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.only(topLeft: Radius.circular(15), topRight: Radius.circular(15)),
color: Theme.of(context).primaryColor,
),
padding: EdgeInsets.all(10),
child: Text(
post.title,
textAlign: TextAlign.left,
style: Theme.of(context).textTheme.headline5.copyWith(color: Colors.white),),
),
Container(
child: Text(
post.body,
style: Theme.of(context).textTheme.bodyText2,
),
padding: EdgeInsets.all(10),
),
],
)
);
}
}
Не забудьте удалить ненужный код из post_list_page.dart
:
import 'package:flutter/material.dart';
import '../../controllers/post_controller.dart';
import '../../models/post.dart';
import 'post_list_item.dart';
import 'package:mvc_pattern/mvc_pattern.dart';
class PostListPage extends StatefulWidget {
@override
_PostListPageState createState() => _PostListPageState();
}
// не забываем расширяться от StateMVC
class _PostListPageState extends StateMVC {
// ссылка на наш контроллер
PostController _controller;
// передаем наш контроллер StateMVC конструктору и
// получаем на него ссылку
_PostListPageState() : super(PostController()) {
_controller = controller as PostController;
}
// после инициализации состояние
// мы запрашивает данные у сервера
@override
void initState() {
super.initState();
_controller.init();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("Post List Page"),
),
body: _buildContent()
);
}
Widget _buildContent() {
// первым делом получаем текущее состояние
final state = _controller.currentState;
if (state is PostResultLoading) {
// загрузка
return Center(
child: CircularProgressIndicator(),
);
} else if (state is PostResultFailure) {
// ошибка
return Center(
child: Text(
state.error,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headline4.copyWith(color: Colors.red)
),
);
} else {
// отображаем список постов
final posts = (state as PostResultSuccess).postList.posts;
return Padding(
padding: EdgeInsets.all(10),
// ListView.builder создает элемент списка
// только когда он видим на экране
child: ListView.builder(
itemCount: posts.length,
itemBuilder: (context, index) {
// мы вынесли элемент списка в
// отдельный виджет
return PostListItem(posts[index]);
},
),
);
}
}
}
Заключение
В последующих частях мы ещё не раз будем сталкиваться с созданием сетевых запросов.
Я постарался кратко рассказать и показать на наглядном примере работу с сетью.
Надеюсь моя статья принесла вам пользу)
Ссылка на Github
Всем хорошего кода!