Flutter. Стиль кода — это все
Стиль кода — это все. Это свежий взгляд на простое и сложное. Стильный, но простой код лучше, чем сложный, но не стильный. Сложный и стильный — это искусство. Разработка может быть искусством, тестирование может быть искусством. Оптимизация кода — тоже искусство.
Не все придерживаются стиля, да и не у всех он есть. В коде может быть больше стиля, чем в дизайне, но не у всего кода есть стиль. Фреймворки безмерно стильные. Когда разработчик находит идеальное сочетание функциональности и читаемости в своем коде, это стильно.
Люди тоже диктуют стиль. Стиль был у Роберта Мартина, у Дэвида Томаса, у Мартина Фаулера, у Эрика Эванса… Я встречал стиль в открытом исходном коде. В среде разработчиков стильных проектов куда больше, чем в каком-либо другом сообществе. Стиль — это атрибут, подход и структура.
Выше — юмористическая интерпретация слов из поэмы Чарльза Буковски «Стиль». Вариативность стиля кода настолько многогранна, что не всегда удается прийти к единому мнению в пользу того или иного подхода, поэтому стиль в сегодняшнем понимании — это целая культура.
Сегодня мы будем разматывать клубок лучших практик и скрытых трюков. После этого ваш код станет не просто рабочим, а настоящим произведением искусства. С учетом моего опыта работы мобильным разработчиком в TAGES, я готов поделиться своими знаниями и практиками, которые могут быть полезны для вашего проекта. Пристегнитесь потуже — мы взлетаем!
Операторы защиты
Операторы защиты (guard statements), также известные как early exit statements или early returns — это техника программирования, которая позволяет проверить условия в начале функции и выйти из нее или вернуть результат, если условие не выполняется. Это помогает упростить код и избежать сильной вложенности.
if (condition) {
// Do things...
}
В Dart эта конструкция обычно реализуются с помощью оператора «if» и ключевого слова «return».
if (!condition) return;
// Do things…
Использование операторов защиты улучшает читаемость кода и делает его более лаконичным, особенно для функций с большим количеством условий. Однако вместе с этим стоит учитывать и ряд важных факторов:
Множественные точки выхода ломают парадигму структурного программирования, так как при чтении кода мы сначала изучаем то, что он не делает, а только потом мы видим, ради чего сюда пришли.
Если код пересек правую границу ограничения в 80 или даже 120 символов, то первостепенной проблемой является не вложенность, а, скорее всего, плохо написанный код.
Операторы защиты могут прерывать выполнение функции или метода, тем самым затрудняя поиск и исправление ошибок в режиме отладки.
Можно сказать, что ценность данной техники заключается в уместности и умеренности ее применения. Операторы защиты могут помочь сделать код более понятным, тем самым облегчив его обслуживание, что немаловажно при работе в команде.
Тернарные операторы
Тернарные операторы (ternary operators) в Dart, как и в других языках программирования, предоставляют компактный способ выбора между двумя выражениями на основе условия. Они имеют следующую синтаксическую структуру:
condition ? expression1 : expression2
Если «condition» истинно, то выполняется «expression1», иначе — выполняется «expression2». Хотя тернарный оператор может быть полезен для создания компактного кода, его лучше не использовать в ситуациях, когда это может привести к ухудшению понимания кода, что и можно увидеть на следующих примерах:
Пример 1. Слишком сложные выражения
final result = a > b ? (x < y ? 'foo' : 'bar') : (p > q ? 'baz' : 'qux');
В этом примере тернарный оператор вложен и используется для сложных условий, что делает код трудным для человеческого понимания.
Пример 2. Замена простых условий
final isEven = (number % 2 == 0) ? true : false;
В данном примере тернарный оператор избыточен, так как условие уже возвращает true или false.
Пример 3. Простые и понятные условия
final message = isLoggedIn ? 'Welcome back!' : 'Please log in.';
В представленном примере тернарный оператор используется для простого и понятного условия. Так код становится лаконичным и легким для понимания.
Пример 4. Компактные условные выражения
final discount = age >= 60 ? 0.2 : 0;
В этом примере тернарный оператор используется для компактного выражения условия.
Тернарные операторы следует использовать с осторожностью и только в тех случаях, когда они действительно улучшают читаемость и компактность кода. В сложных или многоуровневых условиях лучше применять традиционные блоки с ветвлением логики.
Фабричные конструкторы
Фабричные конструкторы в Dart предоставляют способ создания объектов класса с использованием методов, которые необязательно должны выступать конструкторами в традиционном смысле. Они могут принимать аргументы, выполнять дополнительную логику и возвращать экземпляры класса или его подклассов.
Несколько ключевых причин, по которым фабричные конструкторы могут оказаться полезными:
Гибкость создания объектов. Фабричные конструкторы позволяют создавать объекты с различными параметрами и логикой, что делает их более гибкими по сравнению с обычными конструкторами.
Сокрытие деталей реализации. Фабричные конструкторы могут скрывать сложные или избыточные детали при создании объектов.
Возможность возвращать существующие экземпляры. Фабричные конструкторы могут возвращать уже существующие экземпляры объектов, что может быть полезно для реализации паттерна «Одиночка» (Singleton) или для повторного использования объектов.
Возможность возвращать подклассы. Фабричные конструкторы могут возвращать экземпляры подклассов, что позволяет создавать полиморфные объекты без необходимости знания их конкретного типа.
Улучшение читаемости кода. Фабричные конструкторы могут помогать делать код более читаемым и понятным, поскольку они могут иметь осмысленные имена, отражающие их назначение.
Пример 1. Создание виджета с разными стилями
Представим, что у нас есть виджет CustomButton, которому доступны два стиля: primary и secondary.
custom_button.dart
import 'package:flutter/material.dart';
class CustomButton extends StatelessWidget {
const CustomButton._(this.child, this.color, this.onPressed);
factory CustomButton.primary({
required Widget child,
required VoidCallback? onPressed,
}) {
return CustomButton._(child, Colors.blue, onPressed);
}
factory CustomButton.secondary({
required Widget child,
required VoidCallback? onPressed,
}) {
return CustomButton._(child, Colors.grey, onPressed);
}
final Widget child;
final Color color;
final VoidCallback? onPressed;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onPressed,
child: Container(
padding: const EdgeInsets.all(8.0),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8.0),
color: color,
),
child: DefaultTextStyle.merge(
style: const TextStyle(color: Colors.white),
child: Flexible(child: child),
),
),
);
}
}
В этом примере фабричные конструкторы primary и secondary позволяют создавать кнопки с различными цветами, скрывая детали реализации. Это полезно при внедрении дизайн-системы, где каждый компонент уже имеет свое имя и предопределенные характеристики.
Пример 2. Создание виджета с различными реализациями
Бывает так, что виджеты совершенно по-разному выглядят и имеют разную механику, но так или иначе принадлежат одной группе. В классическом сценарии их обычно разделяют на несколько виджетов с разным неймингом, которые между собой никак не связаны. В результате — когда другому разработчику нужно сверстать какой-нибудь новый виджет, возникает проблема: сначала он ищет виджет по проекту, но при поиске однокоренных или смежных слов не находит его. Затем разработчик начинает его реализовывать, публикует изменения, а потом на код-ревью приходит замечание, что такой виджет вообще-то уже есть, просто он лежит в другом месте и называется по-другому.
Чтобы избежать таких недоразумений, на помощь приходят фабричные конструкторы с возвращением своих же подклассов. Для наглядности представим, что у нас есть виджет аватарки, который может принимать либо ссылку на изображение, либо имя пользователя, из которого будут формироваться инициалы.
avatar.dart
import 'dart:math' as math;
import 'package:flutter/material.dart';
sealed class Avatar extends StatelessWidget {
const Avatar({super.key, double? radius}) : radius = radius ?? 20.0;
factory Avatar.url(Uri url, {Key? key, double? radius}) =>
_AvatarNetwork(key: key, url, radius: radius);
factory Avatar.named(String name, {Key? key, double? radius}) =>
_AvatarName(key: key, name, radius: radius);
factory Avatar.fallback({Key? key, double? radius, Uri? url, String? name}) {
if (url != null) return Avatar.url(url, key: key, radius: radius);
return Avatar.named(name ?? '', key: key, radius: radius);
}
final double radius;
}
class _AvatarNetwork extends Avatar {
const _AvatarNetwork(this.url, {super.key, super.radius});
final Uri url;
@override
Widget build(BuildContext context) {
return Container(
width: radius * 2,
height: radius * 2,
decoration: BoxDecoration(
image: DecorationImage(
image: NetworkImage(url.toString()),
fit: BoxFit.cover,
),
shape: BoxShape.circle,
),
);
}
}
class _AvatarName extends Avatar {
const _AvatarName(this.name, {super.key, super.radius});
final String name;
@override
Widget build(BuildContext context) {
return Container(
width: radius * 2,
height: radius * 2,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
shape: BoxShape.circle,
),
child: Center(
child: Text(
name.substring(0, math.min(2, name.length)).toUpperCase(),
style: TextStyle(fontSize: radius * 0.9),
),
),
);
}
}
Из примера видно, что у родительского класса Avatar есть общее поле radius, которое могут использовать дочерние классы. В свою очередь, дочерние классы недоступны для использования напрямую, что заставляет ссылаться только на родительский класс. В нем выбирается только необходимая для отображения аватарки реализация.
Подсказка: если дочерние классы получаются объемными в реализации, их можно разделить на несколько файлов и связать через оператор part of.
Пример 3. Выбор реализации в зависимости от платформы
В процессе написания мультиплатформенного приложения часто приходится иметь дело со специфичным платформенным кодом. Вместо того, чтобы проверять платформу в каждом методе, можно проверить ее лишь один раз и вернуть нужный экземпляр класса, который должен будет соответствовать базовому интерфейсу для всех остальных платформ.
meet_client.dart
import 'dart:io' as io;
abstract interface class MeetClient {
factory MeetClient() {
if (io.Platform.isAndroid || io.Platform.isIOS) {
return const _MeetMobileClient();
} else if (io.Platform.isMacOS ||
io.Platform.isWindows ||
io.Platform.isLinux) {
return const _MeetDesktopClient();
}
return const _MeetUnsupportedClient();
}
Future join(Uri uri);
Future hangUp();
}
class _MeetMobileClient implements MeetClient {
const _MeetMobileClient();
@override
Future join(Uri uri) => throw UnimplementedError();
@override
Future hangUp() => throw UnimplementedError();
}
class _MeetDesktopClient implements MeetClient {
const _MeetDesktopClient();
@override
Future join(Uri uri) => throw UnimplementedError();
@override
Future hangUp() => throw UnimplementedError();
}
class _MeetUnsupportedClient implements MeetClient {
const _MeetUnsupportedClient();
@override
Future join(Uri uri) =>
throw UnsupportedError('Sorry, join method is not supported!');
@override
Future hangUp() =>
throw UnsupportedError('Sorry, hang up method is not supported!');
}
Как видно из реализации, каждой платформе принадлежит свой независимый класс. К слову, существует множество вариантов использования фабричных конструкторов, поэтому их также стоит учитывать при выборе способа реализации. Это открывает новые горизонты для понимания и улучшения своего стиля кода.
Важно: не стоит использовать фабричные конструкторы, если в них содержится чрезмерно сложная и запутанная логика — это может затруднить понимание и дальнейшую поддержку кода.
Приверженность устоявшимся правилам
При разработке приложений на Flutter, особенно в командной среде, важно придерживаться общепринятых соглашений по именованию переменных, функций и классов. Это не только улучшает читаемость кода, но и облегчает его поддержку и развитие. Здесь даже нет необходимости придумывать свои правила, достаточно почитать реализацию Flutter SDK и сформировать для себя общую картину нейминга. Рассмотрим подробнее:
Использование общепринятого нейминга делает код более понятным для других разработчиков, которые знакомы с Flutter. Например, использование типов VoidCallback и ValueChanged или использование поля children в своем виджете для прокидывания списка дочерних виджетов сразу дает понять назначение этих элементов.
Когда новые члены команды присоединяются к проекту, им проще понять структуру и логику кода, если он следует общепринятым соглашениям. Это сокращает время на адаптацию и повышает эффективность работы.
Придерживаясь общепринятого нейминга, вы создаете код, который легко модифицировать. Дополнения в проект вносятся быстрее и с меньшим риском ошибки, поскольку разработчики могут полагаться на знакомые им соглашения.
Когда команда использует одни и те же названия переменных, снижается вероятность недопонимания. Это особенно важно при проведении код-ревью.
Используйте существующие механики
Нередко бывают случаи, когда в Bottom Sheet добавляют обратные вызовы, которые возвращают результат события, произошедшего внутри. Это может выглядеть примерно так:
main.dart
import 'package:flutter/material.dart';
void main() => runApp(const App());
class App extends StatelessWidget {
const App({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(
title: const Text('Bottom Sheet Example'),
),
body: Builder(
builder: (context) => Center(
child: ElevatedButton(
onPressed: () => showModalBottomSheet(
context: context,
builder: (context) => ConfirmationSheet(
onConfirm: () => ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Confirmed')),
),
onCancel: () => ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Cancelled')),
),
),
),
child: const Text('Show Bottom Sheet'),
),
),
),
),
);
}
}
class ConfirmationSheet extends StatelessWidget {
const ConfirmationSheet({
super.key,
required this.onConfirm,
required this.onCancel,
});
final VoidCallback onConfirm;
final VoidCallback onCancel;
@override
Widget build(BuildContext context) {
return ListView(
physics: const ClampingScrollPhysics(),
padding: EdgeInsets.only(
top: 24.0,
bottom: MediaQuery.paddingOf(context).bottom + 16.0,
),
shrinkWrap: true,
children: [
ListTile(
title: const Text('Confirm'),
onTap: () {
Navigator.of(context).pop();
onConfirm();
},
),
ListTile(
title: const Text('Cancel'),
onTap: () {
Navigator.of(context).pop();
onCancel();
},
),
],
);
}
}
Такая реализация идет в разрез с механикой работы навигатора. В данном случае необходимо вернуть результат не через обратный вызов класса, а через метод .pop (), который и должен обеспечить закрытие Bottom Sheet и вернуть результат.
main.dart
import 'package:flutter/material.dart';
void main() => runApp(const App());
class App extends StatelessWidget {
const App({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(
title: const Text('Bottom Sheet Example'),
),
body: Builder(
builder: (context) => Center(
child: ElevatedButton(
onPressed: () async {
final confirmed = await showModalBottomSheet(
context: context,
builder: (context) => const ConfirmationSheet(),
);
if (!context.mounted || confirmed == null) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(confirmed ? 'Confirmed' : 'Cancelled'),
));
},
child: const Text('Show Bottom Sheet'),
),
),
),
),
);
}
}
class ConfirmationSheet extends StatelessWidget {
const ConfirmationSheet({super.key});
@override
Widget build(BuildContext context) {
return ListView(
physics: const ClampingScrollPhysics(),
padding: EdgeInsets.only(
top: 24.0,
bottom: MediaQuery.paddingOf(context).bottom + 16.0,
),
shrinkWrap: true,
children: [
ListTile(
title: const Text('Confirm'),
onTap: () => Navigator.of(context).pop(true),
),
ListTile(
title: const Text('Cancel'),
onTap: () => Navigator.of(context).pop(false),
),
],
);
}
}
Подсказка: если в Bottom Sheet находится больше событий, чем может в себя вместить Boolean, тогда можно использовать Enum, который будет возвращать значение произошедшего события в ответе .pop ().
Сила в правде
В мире программирования принципы DRY (Don’t Repeat Yourself) и KISS (Keep It Simple, Stupid) являются фундаментальными руководящими идеями. Они направлены на создание более эффективного и легко поддерживаемого кода. Однако, несмотря на их широкое признание, разработчики нередко сталкиваются с соблазном от них отойти.
Принцип DRY подчеркивает следующее: важно избегать повторения информации в коде, чтобы каждый элемент был уникальным источником истины. Дублирование кода часто приводит к таким проблемам, как увеличение объема кода, сложность его поддержки и повышенная вероятность ошибок при внесении изменений. Несмотря на это, разработчики могут невольно дублировать код из-за спешки, недостаточного понимания проекта или просто потому, что им показалось, что это упростит текущую задачу.
KISS, с другой стороны, призывает к созданию наиболее простого и понятного кода. Суть принципа заключается в предотвращении чрезмерной сложности и ненужной абстракции. В то же время, разработчики иногда упрощают задачи до такой степени, что повторяют решения.
Рассмотрим проблему дублирования бизнес-логики на примере сущности «Video», с которым может взаимодействовать пользователь. Для этого сформируем бизнес-требования:
Удалить видео может только автор.
Отредактировать видео может только автор при условии, что оно было успешно обработано и загружено на сервер.
Посмотрим на информационную модель:
video.dart
enum VideoStatus {
processed,
failed,
completed;
bool get isCompleted => this == VideoStatus.completed;
}
class Video {
const Video({
required this.id,
required this.title,
required this.description,
required this.url,
required this.status,
required this.ownerId,
});
final int id;
final String title;
final String? description;
final Uri? url;
final VideoStatus status;
final int ownerId;
}
Чтобы проверить, может ли текущий пользователь удалить видео мы сравниваем идентификатор автора видео с текущим идентификатором пользователя. Для проверки редактирования нужно дополнительно проверить статус видео:
main.dart
void main({int currentUserId = 10000}) {
final video = Video(
id: 1,
title: 'Example',
description: null,
url: Uri.parse('https://example.com/01.mp4'),
status: VideoStatus.completed,
ownerId: 10000,
);
// В лучшем случае результат проверки будет явным
final canEdit = currentUserId == video.ownerId && video.status.isCompleted;
final canDelete = currentUserId == video.ownerId;
if (canEdit) {
// TODO: Показывать кнопку редактирования видео
} else if (canDelete) {
// TODO: Показывать кнопку удаления видео
}
// В худшем случае результат проверки будет неявным
if (currentUserId == video.ownerId && video.status.isCompleted) {
// TODO: Показывать кнопку редактирования видео
} else if (currentUserId == video.ownerId) {
// TODO: Показывать кнопку удаления видео
}
}
Задача кажется тривиальной, но обычно на этом и попадаются. Разработчики неоправданно дублируют бизнес-логику в разных слоях приложения. В итоге образуется сложная и хрупкая структура, затрудняющая внесение новых изменений. Это не только увеличивает время разработки, но и увеличивает вероятность ошибок и других несоответствий в работе приложения.
Хочется решить эту проблему раз и навсегда. Для этого запираем истину в одном месте. Проще всего это сделать на уровне данных, немного изменив модель:
video.dart
enum VideoOperation {
edit,
delete,
}
enum VideoStatus {
processed,
failed,
completed;
bool get isCompleted => this == VideoStatus.completed;
}
class Video {
const Video({
required this.id,
required this.title,
required this.description,
required this.url,
required this.status,
required this.ownerId,
required this.operations,
});
factory Video.fromJson(
Map json, {
required int currentUserId,
}) {
final status = VideoStatus.values[int.parse(json['status'].toString())];
final ownerId = int.parse(json['owner_id'].toString());
return Video(
id: int.parse(json['id'].toString()),
title: json['name'].toString(),
description: json['description']?.toString(),
url: Uri.tryParse(json['url'].toString()),
status: status,
ownerId: ownerId,
operations: VideoOperation.values.where((o) {
return switch (o) {
VideoOperation.edit => currentUserId == ownerId && status.isCompleted,
VideoOperation.delete => currentUserId == ownerId,
};
}).toList(),
);
}
final int id;
final String title;
final String? description;
final Uri? url;
final VideoStatus status;
final int ownerId;
final List operations;
bool get canEdit => operations.contains(VideoOperation.edit);
bool get canDelete => operations.contains(VideoOperation.delete);
}
В модели появился список доступных операций — VideoOperation, который вычисляется при получении или обновлении данных (см. фабричный конструктор Video.fromJson). Такой вид представления данных приятно использовать в любом слое приложения, а расширять и дополнять — вдвойне.
Более чем уверен, что после этого QA-инженеры будут поражены сокращением количества багов. Данный подход не только улучшает качество кода, но и делает процесс разработки более эффективным и предсказуемым.
Несмотря на то, что принципы DRY и KISS являются мощными инструментами для создания качественного кода, их соблюдение требует внимательности и дисциплины. Разработчикам следует постоянно анализировать свой код на предмет дублирования и простоты, чтобы максимально приблизиться к идеалу чистого и эффективного программирования.
Заключение
В заключение темы, важно подчеркнуть, что применение различных подходов и практик должно быть обоснованным и разумным. Операторы защиты и тернарные операторы, при правильном использовании, способны значительно упростить и улучшить читаемость кода, особенно в контексте командной работы. Однако, чрезмерное или неправильное их применение может привести к обратному эффекту, затрудняя понимание и поддержание проекта.
Фабричные конструкторы представляют собой мощный инструмент, но их сложность должна быть пропорциональна задачам, которые они решают. Излишне сложная логика внутри этих конструкторов может создать барьеры для новых разработчиков, пытающихся разобраться в коде.
Наконец, принцип источника единой правды, заключающийся в недопущении дублирования кода, способствует созданию более надежного и управляемого программного обеспечения. Этот принцип подчеркивает важность поддержания чистоты и эффективности любого кода, что является основой для долгосрочного успеха любого проекта.
Все эти аспекты формируют уникальный стиль кода у каждого разработчика, который положительно влияет на читаемость, поддерживаемость и качество программного обеспечения.
Буду признателен, если вы поделитесь в комментариях своими приемами, которые делают ваш подход к разработке не просто эффективным, а настоящим искусством!