Как написать чистый код для Android
Перевод для GeekBrains популярной статьи с Medium.
Дядя Боб говорил в своей книге: «Вы читаете эту статью по двум причинам. Во-первых, вы разработчик. Во-вторых, вы хотите стать лучше как разработчик».
Представьте, что вы в библиотеке и ищете книгу. Если издания рассортированы и каталогизированы, вы быстро найдёте нужное. А если у библиотеки приятный интерьер и архитектура, во время поиска вам будет особенно комфортно.
Так же и с кодом. Если вы хотите создать что-то хорошее, нужно уметь писать понятный код и организовывать его в проекте. Когда коллегам понадобится найти что-то в коде, будет достаточно посмотреть названия методов, классов и пекеджей, чтобы разобраться. И не придётся переписывать всё с нуля, махнув на вашу работу рукой.
Что такое чистый код
Как вы понимаете, недостаточно просто побыстрее написать приложение. Если другим разработчикам будет тяжело в нём разобраться, это просто увеличит технический долг.
Код можно назвать чистым, если его с ходу понимают другие разработчики. Тогда его можно читать и изменять, расширять и поддерживать.
Почему это важно
Код отражает ваш мыслительный процесс. Поэтому нужно стараться сделать код более простым и читаемым.
Основные характеристики чистого кода
- Изящный — он должен заставлять вас улыбаться, как когда любуетесь хорошо спроектированным домом или пробуете вкусную еду.
- О нём позаботились — вы потратили много времени, чтобы структурировать и упростить код. Уделили время деталям.
- Сфокусированный — каждый метод, класс и модуль преследуют одну понятную цель и не перегружены деталями.
- В нём нет повторяемых функций, функциональность не дублируется.
- Проходит все тесты.
- Количество сущностей — методов, классов и абстракций — сведено к необходимому минимуму.
Разница между умным разработчиком и профессиональным в том, что второй ставит читаемость во главу угла. Профессионалы используют весь свой опыт, чтобы писать код, который поймут остальные.
Роберт Мартин
Создавайте говорящие названия
Выбор хорошего говорящего названия отнимает время, но в итоге экономит ещё больше. Название переменной, метода или класса должно отвечать на все основные вопросы. То есть объяснять, для чего этот класс (метод, переменная), что он делает и как его использовать. Если приходится пояснять что-то в комментариях — значит, название недостаточно говорящее. Посмотрим на пример:
//Плохие названия var a = 0 //возраст var w = 0 //вес var h = 0 //рост //Плохие названия методов fun age() fun weight() fun height() //Плохое название для класса получения пользовательских данных class UserInfo() //Хорошие названия var userAge = 0 var userWeight = 0 var userHeight = 0 //Хорошие названия fun setUserAge() fun setUserWeight() fun setUserHeight() //Хорошие названия class User()
Имена классов
Названия классов и объектов должны быть существительными типа Customer, WikiPage, Account, AddressParser. Избегайте в именах слов Manager, Processor, Data или Info, потому что они мало говорят о функциональности объекта. Не используйте глаголы.
Имена методов
В названиях методов как раз нужны глаголы типа postPayment, deletePage, save.
Используйте названия из предметной области
Если общепринятого термина нет, используйте название из предметной области. Тогда разработчик, который поддерживает ваш код, сможет спросить о его значении эксперта.
Комментарий автора статьи
Например, вы пишете софт для телекоммуникационного оборудования. Ваши области задачи (экспертизы, решения) — это текстовое и голосовое общение, видеосвязь. Неважно, на каком языке, как и на какой платформе вы пишете, — предметная область от этого не меняется. Если вы разрабатываете приложение для создания и продажи фотоконтента, то предметная область включает фотографию и e-commerce. Любой человек, который видит ваш код в первый раз, должен сразу понимать, чем занимается каждый класс, модуль или пекедж в вашем приложении. Думайте об этом, когда добавляете в название что-то из предметной области этого класса.
Пишем код, используя принципы SOLID
Эти принципы были введены в практику ООП Робертом Мартином, а SOLID — мнемоническая аббревиатура для них. Они описывают подход к проектированию простых, читаемых и надёжных программных решений не только в Android-разработке, но и в целом в объектно-ориентированном программировании.
Принцип единой ответственности (Single Responsibility Principle — SRP)
У класса должна быть одна задача. Изменить состояние класса можно только для того, чтобы выполнить конкретную задачу. Не добавляйте в класс функциональность просто потому, что можете. Если класс выполняет разные задачи — разбейте его на два или вынесите часть задач в другие классы. Избегайте божественных классов. Посмотрим на пример:
class MyAdapter(val friendList: List<FriendListData.Friend>) : RecyclerView.Adapter<CountryAdapter.MyViewHolder>() { override fun onBindViewHolder(holder: MyViewHolder, position: Int) { val friend = friendList[position] val status = if(friend.maritalStatus == "Married") { "Sold out" } else { "Available" } holder.name.text = friend.name holder.popText.text = friend.email holder.status.text = status } override fun getItemCount(): Int { return friendList.size } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MyViewHolder { val view = LayoutInflater.from(parent.context).inflate(R.layout.item_friendlist, parent, false) return MyViewHolder(view) } inner class MyViewHolder(view: View) : RecyclerView.ViewHolder(view) { var name: TextView = view.findViewById(R.id.text1) var popText: TextView = view.findViewById(R.id.text2) } } }
В классе RecyclerView.Adapter есть метод onBindViewHolder, который занимается посторонними задачами. Adapter должен только создавать ViewHolder и передавать в него данные. Он не должен обрабатывать эти данные в методе onBindViewHolder. Даже само название метода говорит нам об этом.
Принцип открытости-закрытости (Open-Closed Principle — OCP)
Классы должны быть открыты для расширения, но закрыты для изменения. Иными словами, если кто-то захочет внести коррективы в ваш класс А, то его можно расширить (отнаследоваться от него), но никак не менять напрямую.
Простой пример — тот же RecyclerView.Adapter. Вы можете отнаследоваться от него, чтобы создать свою имплементацию с необходимыми вам свойствами и поведением. Но вы не можете внести эти изменения непосредственно в RecyclerView.Adapter.
class FriendListAdapter(val friendList: List<FriendListData.Friend>) : RecyclerView.Adapter<CountryAdapter.MyViewHolder>() { inner class MyViewHolder(view: View) : RecyclerView.ViewHolder(view) { var name: TextView = view.findViewById(R.id.text1) var popText: TextView = view.findViewById(R.id.text2) } override fun onBindViewHolder(holder: MyViewHolder, position: Int) { val friend = friendList[position] holder.setData(friend) } override fun getItemCount(): Int { return friendList.size } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MyViewHolder { val view = LayoutInflater.from(parent.context).inflate(R.layout.item_friendlist, parent, false) return MyViewHolder(view) } }
Принцип подстановки Барбары Лисков (Liskov Substitution Principle — LSP)
Класс-потомок никогда не изменяет поведение класса-родителя. То есть подкласс может переопределять методы родительского класса, только если это не меняет его функциональность.
Например, вы создаёте интерфейс, у которого есть метод onClick(). Затем вы имплементируете интерфейс в MyActivity, и когда onClick() вызовется — отобразите в нём Toast. Не прописывайте эту функциональность в интерфейсе. Дополняйте onClick(), только переопределяя его в классе-потомке.
interface ClickListener { fun onClick() } class MyActivity: AppCompatActivity(), ClickListener { //........ override fun onClick() { //Новая функциональность в переопределённом методе toast("OK button clicked") } }
Принцип разделения интерфейсов (Interface Segregation Principle — ISP)
Ни один наследник не должен имплементировать методы, которые он не использует. Если у вас есть класс или интерфейс А и вы имплементируете его в классе B, то не нужно переопределять все методы А в B.
Для примера рассмотрим имплементацию в вашей Activity SearchView.OnQueryTextListener(). Вам нужен оттуда только один метод, но там их два:
mSearchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener{ override fun onQueryTextSubmit(query: String?): Boolean { //Вам нужен только этот метод return true } override fun onQueryTextChange(query: String?): Boolean { //Вам не нужен этот метод return false } })
Как сделать так, чтобы вы не имплементировали ненужную функциональность? Отнаследуйтесь от SearchView.OnQueryTextListener(), создайте свой колбэк и передавайте дальше только то, что вам нужно:
//Ваш собственный колбэк interface SearchViewQueryTextCallback { fun onQueryTextSubmit(query: String?) } //Отнаследуйтесь от SerchView и вызывайте ваш колбэк только там, где вам нужно class SearchViewQueryTextListener(val callback: SearchViewQueryTextCallback): SearchView.OnQueryTextListener { override fun onQueryTextSubmit(query: String?): Boolean { //Вызываем колбэк callback.onQueryTextSubmit(query) return true } override fun onQueryTextChange(query: String?): Boolean { //Не вызываем колбэк return false } }
Как это будет выглядеть в нашей Activity:
val listener = SearchViewQueryTextListener( object : SearchViewQueryTextCallback { override fun onQueryTextSubmit(query: String?) { //Ваш код } } ) mSearchView.setOnQueryTextListener(listener)
Или можно написать функцию-расширение, как это принято в Kotlin:
interface SearchViewQueryTextCallback { fun onQueryTextSubmit(query: String?) } fun SearchView.setupQueryTextSubmit (callback: SearchViewQueryTextCallback) { setOnQueryTextListener(object : SearchView.OnQueryTextListener{ override fun onQueryTextSubmit(query: String?): Boolean { callback.onQueryTextSubmit(query) return true } override fun onQueryTextChange(query: String?): Boolean { return false } }) }
Как это будет выглядеть в Activity:
val listener = object : SearchViewQueryTextCallback { override fun onQueryTextSubmit(query: String?) { //Ваш код } } mSearchView.setupQueryTextSubmit(listener)
Принцип инверсии зависимостей (Dependency Inversion Principle — DIP)
Зависимости должны быть от абстракций, а не от конкретных имплементаций. Роберт Мартин приводит два аргумента:
- Верхнеуровневые модули не должны зависеть от низкоуровневых. И те и другие должны зависеть от абстракций.
- Абстракции не должны зависеть от деталей, детали должны зависеть от абстракций.
Высокоуровневые модули, в которых прописана сложная бизнес-логика, должны легко переиспользоваться и не зависеть от изменений в низкоуровневых модулях. Чтобы это работало, вам нужно определить абстракции, которые отделяют высокоуровневые модули от низкоуровневых.
Простой пример — паттерн MVP, где вы определяете интерфейсы, которые помогают передавать данные из одного конкретного класса в другой. Это значит, что UI-часть (Activity/Fragment) не должна ничего знать о том, как именно работают методы Presenter. И если вы измените что-то в Presenter, Activity этого даже не заметит. Посмотрим на примере. Presenter имплементирует интерфейс:
interface UserActionListener { fun getUserData() } class UserPresenter : UserActionListener() { // ..... override fun getUserData() { val userLoginData = gson.fromJson(session.getUserLogin(), DataLogin::class.java) } // ..... }
То же самое в Activity:
class UserActivity : AppCompatActivity() { //..... val presenter : UserActionListener = UserPresenter() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) //Activity не знает, как Presenter достаёт для неё данные //она просто вызывает у Presenter соответствующий метод //Поэтому если вы добавите метод в Presenter или измените источник данных, то в Activity ничего не сломается. presenter.getUserData() } //.... }
Мы создали интерфейс, отделяющий имплементацию Presenter от Activity, которая содержит ссылку на интерфейс, а не на Presenter.
Если вы уже разрабатывали приложение, в котором были бессмысленные названия, божественные классы, спагетти-код — поверьте, я тоже такое делал. Поэтому и делюсь знаниями от Дяди Боба о чистом коде. Надеюсь, они вам помогут.