Мой шаблон Android приложения для Pet-проектов
Приветствую всех любителей покодить)
В течение года разработки небольшого приложения я рефакторил код, что-то менял, удалял, добавлял и пришел к интересной комбинации различных практик и решений, которая впоследствии стала шаблоном для собственных Pet-проектов.
Начну пожалуй с того, чтобы попросить вас не писать комментарии из разряда: "сейчас есть современный стэк, а это все никому не нужно", "ваш код - говнокод", "автор тупо пиарит никому ненужный репозиторий" и так далее. Также если для вас "писать свою навигацию на вьюшках" и "хранить вьюмодель в Application классе" считаются абсурдными практиками, незамедлительно скипайте эту статью.
Ну и собственно поводом написания этой статьи у меня было желание чем-то поделиться с людьми, пусть даже это будет мой незаурядный шаблон.
Почему незаурядный? Приведу несколько особенностей, исходя из которых так можно посчитать (а может нельзя, решайте сами):
- Верстка кодом с использованием своих собственных Kotlin Extensions (похожий стиль реализован в таких либах как Anko или Splitties)
- Хранение самописных вьюмоделек в Application классе
- Навигация на View'ах (есть более крутая либа чем мой вариант такая как Conductor)
- Замена LiveData'ы на самописные Observable свойства которые не требуют подписки на жизненный цикл компонента
- Выполнение задач на разных потоках с помощью Handler'a и Executor API
Прикрепляю ссылку на репозиторий.
Что ж, давайте пройдемся по основным моментам моего шаблона.
Верстка разметки кодом
Чтобы разместить вьюшку в каком либо контейнере (LinearLayout, FrameLayout) нам нужно указать для нее LayoutParams'ы, поэтому я создал специальные классы для упрощения этого процесса (импорты опущены):
// общий класс для всех LayoutParams'ов, для которых можно указать ширину
// и высоту (ViewGroup)
abstract class AbstractLP<T : ViewGroup.LayoutParams, R>(private val params: T, private val match: Int, private val wrap: Int): LP<T> {
abstract fun with(params: T) : R
fun matchWidth() = with(params.apply { width = match })
fun matchHeight() = with(params.apply { height = match })
fun match() = with(params.apply {
width = match
height = match
})
fun wrapWidth() = with(params.apply { width = wrap })
fun wrapHeight() = with(params.apply { height = wrap })
fun wrap() = with(params.apply {
width = wrap
height = wrap
})
fun width(dp: Int) = with(params.apply { width = dp })
fun height(dp: Int) = with(params.apply { height = dp })
override fun build(): T = params
}
...
// общий класс для LayoutParams'ов, которые поддерживают отступы (LinearLayout, FrameLayout)
abstract class AbstractMarginLP<T : ViewGroup.MarginLayoutParams, R>(private val params: T, match: Int, wrap: Int) : AbstractLP<T, R>(params, match, wrap) {
fun marginTop(dp: Int) = with(params.apply { topMargin = dp })
fun marginStart(dp: Int) = with(params.apply { marginStart = dp })
fun marginEnd(dp: Int) = with(params.apply { marginEnd = dp })
fun marginBottom(dp: Int) = with(params.apply { bottomMargin = dp })
fun marginVertical(dp: Int) = with(params.apply {
topMargin = dp
bottomMargin = dp
})
fun marginHorizontal(dp: Int) = with(params.apply {
marginStart = dp
marginEnd = dp
})
fun margins(dp: Int) = with(params.apply {
marginStart = dp
marginEnd = dp
topMargin = dp
bottomMargin = dp
})
fun margins(startDp: Int, topDp: Int, endDp: Int, bottomDp: Int) = with(params.apply {
marginStart = startDp
marginEnd = endDp
topMargin = topDp
bottomMargin = bottomDp
})
}
...
private const val match = FrameLayout.LayoutParams.MATCH_PARENT
private const val wrap = FrameLayout.LayoutParams.WRAP_CONTENT
// конкретная реализация для FrameLayout.LayoutParams'ов
class FrameLayoutLP(private val params: FrameLayout.LayoutParams = FrameLayout.LayoutParams(wrap, wrap))
: AbstractMarginLP<FrameLayout.LayoutParams, FrameLayoutLP>(params, match, wrap) {
fun gravity(grav: Int) = FrameLayoutLP(params.apply { gravity = grav })
override fun with(params: FrameLayout.LayoutParams): FrameLayoutLP = FrameLayoutLP(params)
}
...
// расширение для красоты и упрощения
fun View.layoutParams(params: AbstractLP<*, *>) {
layoutParams = params.build()
}
...
// мне больше нравится наименование функций в таких случаях
fun linearLayoutParams() = LinearLayoutLP()
fun frameLayoutParams() = FrameLayoutLP()
fun viewGroupLayoutParams() = ViewGroupLP()
fun recyclerLayoutParams() = RecyclerViewLP()
Теперь приведу парочку примеров как это выглядит:
// указываем FrameLayout.LayoutParams для заголовка тулбара
toolbarTitleView.layoutParams(frameLayoutParams()
.wrap() // указать wrap_content для ширины и высоты одновременно
.gravity(Gravity.CENTER)
.marginStart(margin)
.marginEnd(margin))}
// указываем FrameLayout.LayoutParams для стрелки назад
backIconView.layoutParams(frameLayoutParams()
.width(context.dp(24))
.height(context.dp(24))
.gravity(Gravity.CENTER))
// указываем LinearLayout.LayoutParams для текста описания
contentView.layoutParams(linearLayoutParams()
.matchWidth()
.wrapHeight()
.marginTop(context.dp(8)))Давайте глянем как реализована темизация в моем случае:
// CoreTheme определяет параметры темы: цвета, закругления, размеры и тд
enum class CoreTheme
// я оставил только один параметр для упрощения
val backgroundColor: Int,
...
) {
LIGHT(
backgroundColor = CoreColors.white,
...
),
DARK(
backgroundColor = CoreColors.black,
...
)
}
...
// CoreThemeManager отвечает за переключение тем и оповещения вьюшек об этом
class CoreThemeManager(private val themeDataStorage: PersistenceSimpleDataStorage) {
private val listeners = mutableListOf<(CoreTheme) -> Unit>()
// themeDataStorage - это просто обертка над SharedPreferences'ами
// которая возвращает сохраненное число enum'ки темы
private var currentTheme = CoreTheme.values()[themeDataStorage.int(theme_key, CoreTheme.LIGHT.ordinal)]
// мне нравится писать свойства-геттеры в стиле lowercase + underscore
val selected_theme: CoreTheme
get() = currentTheme
// актуальная иконка для переключения с одной темы на другую
val menu_button_icon_drawable_resource: Int
get() = if (currentTheme == CoreTheme.LIGHT) R.drawable.ic_dark_theme else R.drawable.ic_light_theme
// во вьюшке подписываемся на изменение темы
fun listenForThemeChanges(listener: (CoreTheme) -> Unit) {
listeners.add(listener)
listener.invoke(currentTheme)
}
// когда вьюшка больше не нужна, отписываемся
fun doNotListenForThemeChanges(listener: (CoreTheme) -> Unit) {
listeners.remove(listener)
}
// переключение с темной на светлую и обратно
fun toggleTheme() {
currentTheme = if (currentTheme == CoreTheme.LIGHT) CoreTheme.DARK else CoreTheme.LIGHT
themeDataStorage.save(theme_key, currentTheme.ordinal)
listeners.forEach { listener -> listener.invoke(currentTheme) }
}
companion object {
private const val theme_key = "CoreThemeManager_key"
}
}
...
// темизированный вариант TextView
class CoreTextView(context: Context): AppCompatTextView(context) {
// вызывается в двух случаях:
// 1) при первом создании вьюшки
// 2) при изменении темы
private val onThemeChanged: (CoreTheme) -> Unit = { theme ->
setTextColor(theme.textColor)
}
// themeManager является Singleton'ом и хранится в Application классе
private val themeManager = (context.applicationContext as App).themeManager
// подписываемся на изменения темы в нужный момент
override fun onAttachedToWindow() {
super.onAttachedToWindow()
themeManager.listenForThemeChanges(onThemeChanged)
}
// отписываемся когда это больше не нужно
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
themeManager.doNotListenForThemeChanges(onThemeChanged)
}
}Вы можете добавить другие параметры помимо цвета, такие как размер, радиус закругления и даже шрифты (если заморочитесь насчет последнего).
Посмотрим кусочек сверстанного экрана:
val rootView = CoreLinearLayout(context) rootView.orientation = LinearLayout.VERTICAL rootView.padding(context.dp(16)) val titleView = CoreTextView(context) titleView.typeface = context.roboto_bold titleView.setTextColor(CoreColors.black) titleView.fontSize(21f) titleView.layoutParams(linearLayoutParams().matchWidth().wrapHeight()) rootView.addView(titleView) val contentView = CoreTextView(context) contentView.fontSize(18f) contentView.typeface = context.roboto_regular contentView.setTextColor(CoreColors.black) contentView.layoutParams(linearLayoutParams().matchWidth().wrapHeight().marginTop(context.dp(8))) rootView.addView(contentView)
Выглядит достаточно понятно и элегантно по моему мнению.
Напоследок рассмотрим использование шрифтов из Android Assets:
// чтобы повторно не дергать получение определенного шрифта
// при первом использовании сохраняем шрифт в HashMap'у
private val typefaceCache = hashMapOf<String, Typeface>()
private fun Context.typefaceGetter(key: String): Typeface {
if (!typefaceCache.containsKey(key)) {
val font = Typeface.createFromAsset(resources.assets, key)
typefaceCache[key] = font
}
return typefaceCache[key]!!
}
val Context.roboto_bold: Typeface
get() = typefaceGetter("Roboto-Bold.ttf")
val Context.roboto_medium: Typeface
get() = typefaceGetter("Roboto-Medium.ttf")
val Context.roboto_regular: Typeface
get() = typefaceGetter("Roboto-Regular.ttf")
...
// устанавливаем жирный шрифт для заголовка тулбара
toolbarTitleView.typeface = context.roboto_mediumХранение вьюмоделек в Application классе
Я не использую вьюмодельки от гугла, а создаю собственные, которые хранятся в Application классе и которые не будут пересоздаваться при изменении конфигурации или нехватки памяти:
// интерфейс вьюмодели, определяет в каком жизненном цикле находится экран
interface PersistenceViewModel {
fun onDestroyComponentCalled() {}
fun onDestroyViewCalled() {}
}
...
class App: Application() {
...
// кэш для вьюмоделей
val viewModelMemoryTypedCache = KeyedMemoryTypedCache<PersistenceViewModel>()
....
}KeyedMemoryTypedCache<*> является всего лишь простейшей параметризированной оберткой над HashMap'ой:
class KeyedMemoryTypedCache<T> {
private val internal = HashMap<String, T>()
fun save(key: String, obj: T) {
internal[key] = obj
}
fun read(key: String): T? {
return internal[key]
}
fun remove(key: String): Boolean {
return internal.remove(key) != null
}
}Позже я наглядно покажу как реализована вьюмоделька и как дергаются методы ее интерфейса.
Навигация на вьюшках
Рассмотрим определение отдельного экрана в приложении:
abstract class ScreenView: DefaultLifecycleObserver {
// ссылка на вьюмодельку
protected var viewModel: PersistenceViewModel? = null
// конструктор для конкретной реализации в подклассах
protected open fun viewModelConstructor(ctx: Context): PersistenceViewModel? = null
// корневая вьюшка нашего экрана
var view: View? = null
// ссылка на кэш вьюмоделей из Application класса
private var viewModelCache: KeyedMemoryTypedCache<PersistenceViewModel>? = null
// ссылка на навигатор между экранами
protected var screenNavigator: ScreenViewNavigator? = null
fun changeNavigator(navigator: ScreenViewNavigator) {
screenNavigator = navigator
}
fun clearNavigator() {
screenNavigator = null
}
// вызывается при построении иерархии вьюшек нашего экрана
// если вьюмодель была в кэше, то она восстановится
fun createView(context: Context, bundle: Bundle?): View {
viewModelCache = (context.applicationContext as? App)?.viewModelMemoryTypedCache
viewModel = viewModelCache?.read(view_model_key) ?: viewModelConstructor(context)
val myView = view(context, bundle)
view = myView
return myView
}
protected abstract fun view(context: Context, bundle: Bundle?): View
// вызывается когда экран окончательно уничтожается из backstack'а приложения (кнопка назад)
fun cleanCachedData() {
viewModel?.onDestroyComponentCalled()
viewModelCache?.remove(view_model_key)
}
// сохраняет вьюмодельку при изменении конфигурации или нехватки ресурсов
override fun onDestroy(owner: LifecycleOwner) {
val viewModel = viewModel
if (viewModel != null) {
viewModel.onDestroyViewCalled()
viewModelCache?.save(view_model_key, viewModel)
}
// обязательно обнуляет ссылки
viewModelCache = null
view = null
clearNavigator()
super.onDestroy(owner)
}
companion object {
private const val view_model_key = "viewModel"
}
}Как вы заметили тут используется DefaultLifecycleObserver из appcompat'а. Я считаю это удобным способом для пробрасывания callback'ов жизненного цикла.
Теперь посмотрим как реализован сам навигатор:
// навигатор принимает четыре параметра, такие как корневая вьюшка,
// сохраненный бандл, жизненный цикл активити и сохраненный backstack
class ScreenViewNavigator(
private val frameLayoutView: FrameLayout,
private val savedInstanceState: Bundle?,
private val activityLifecycle: Lifecycle,
private val backstackCache: KeyedMemoryTypedCache<List<ScreenView>>
) {
private val backstack = mutableListOf<ScreenView>()
val has_posibillity_to_navigate_back: Boolean
get() = backstack.size > 1
init {
// здесь происходит восстановление backstack'а в случае
// изменения конфигурации или нехватки памяти
backstack.clear()
val cachedBackstack = backstackCache.read(backstack_key).orEmpty()
var index = 0
while(index < cachedBackstack.size) {
push(cachedBackstack[index])
index++
}
}
// добавляем новый экран в backstack
fun push(screenView: ScreenView) {
screenView.changeNavigator(this)
backstack.add(screenView)
backstackCache.save(backstack_key, backstack)
updateUIWhenViewHasBeenAddedInBackstack(screenView)
}
// удаляем экран из backstack'а и возвращаем true если backstack не был пуст
fun pop(): Boolean {
val removedScreen = backstack.removeLastOrNull()
if (removedScreen != null) {
removedScreen.clearNavigator()
backstackCache.save(backstack_key, backstack)
updateUIWhenViewHasBeenRemovedFromBackstack(removedScreen)
val currentScreen = backstack.lastOrNull()
return if (currentScreen != null) {
updateUIWhenViewHasBeenAddedInBackstack(currentScreen)
true
} else {
false
}
}
return false
}
// при удалении экрана из backstack'а мы должны сделать следующие вещи:
//
// 1) отписаться от изменений жизненного цикла
// 2) вызвать метод cleanCachedData() экрана для окончательной его очистки
// 3) удалить корневую вьюшку экрана из иерархии
private fun updateUIWhenViewHasBeenRemovedFromBackstack(screen: ScreenView) {
activityLifecycle.removeObserver(screen)
screen.cleanCachedData()
frameLayoutView.removeView(screen.view)
}
// при добавлении нового экрана в backstack мы должны сделать следующие вещи:
//
// 1) сделать проверку на уже добавленный экран
// 2) подписаться на изменения жизненного цикла
// 3) добавить корневую вьюшку экрана в иерархию
private fun updateUIWhenViewHasBeenAddedInBackstack(screenView: ScreenView) {
if (screenView.view != null) return
activityLifecycle.addObserver(screenView)
val newScreenView = screenView.createView(frameLayoutView.context, savedInstanceState)
newScreenView.layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT
)
frameLayoutView.addView(newScreenView)
}
companion object {
private const val backstack_key = "backstack"
}
}
...
class App: Application() {
// backstack хранится в Application классе
val screenMemoryTypedCache = KeyedMemoryTypedCache<List<ScreenView>>()
}Теперь приведу пример простенького экранчика:
// TitleScreen производный от ScreenView (чекните в репозитории),
// добавляет тулбар, кнопку назад и кнопку меню
class PostDetailScreen(private val model: PostModel): TitleScreen() {
override fun content(context: Context, bundle: Bundle?): View {
val rootView = CoreLinearLayout(context)
rootView.orientation = LinearLayout.VERTICAL
rootView.padding(context.dp(16))
val titleView = CoreTextView(context)
titleView.typeface = context.roboto_bold
titleView.setTextColor(CoreColors.black)
titleView.fontSize(21f)
titleView.layoutParams(linearLayoutParams().matchWidth().wrapHeight())
rootView.addView(titleView)
val contentView = CoreTextView(context)
contentView.fontSize(18f)
contentView.typeface = context.roboto_regular
contentView.setTextColor(CoreColors.black)
contentView.layoutParams(linearLayoutParams().matchWidth().wrapHeight().marginTop(context.dp(8)))
rootView.addView(contentView)
// так как экземпляры экранов хранятся в Application классе, то
// нам не нужно беспокоиться об их параметрах
with(model) {
title(titleView)
content(contentView)
}
return rootView
}
}Осталось глянуть на самописные свойства и многопоточность и сделать заключительные выводы насчет моего шаблона.
Observable свойства и многопоточность
Как уже было сказано ранее я решил написать свою LiveData'у без учитывания жизненного цикла и создал для этого парочку Java-классов.
Почему Java? Потому что не совсем понял как в Kotlin'е красиво можно сделать изменяемые и неизменяемые свойства):
// интерфейс Observer'a
public interface ObservablePropertyInterface<T> {
void onChanged(T t);
}
...
// неизменяемое свойство, аналогичное LiveData'е, только без учитывания
// жизненного цикла и с одним Observer'ом
public class ObservableProperty<T> {
private T value;
private ObservablePropertyInterface<T> propertyInterface;
public ObservableProperty(T defaultValue) {
this.value = defaultValue;
}
protected void setValue(T value) {
this.value = value;
if (propertyInterface != null) {
propertyInterface.onChanged(value);
}
}
public T getValue() {
return value;
}
public void addChangedListener(ObservablePropertyInterface<T> propertyInterface) {
this.propertyInterface = propertyInterface;
propertyInterface.onChanged(value);
}
public void clearChangedListeners() {
propertyInterface = null;
}
}
...
// изменяемое свойство, которое переопределяет protected метод
// у неизменяемого и делает его публичным
public class MutableObservableProperty<T> extends ObservableProperty<T> {
public MutableObservableProperty(T defaultValue) {
super(defaultValue);
}
@Override
public void setValue(T value) {
super.setValue(value);
}
}Чтобы увидеть наши Observable свойства в действии чекним код вьюмодельки, который кстати я обещал ранее показать:
class PostListViewModel(private val repo: PostRepository): PersistenceViewModel {
// метод submit из ExecutorService возвращает обьект задачи Future<*>,
// которую можно отменить (у корутин отмена происходит через job'ы)
private val futures = mutableListOf<Future<*>>()
// наглядное использование свойств
private val _state: MutableObservableProperty<PostListState> = MutableObservableProperty(PostListState.Loading)
val state: ObservableProperty<PostListState>
get() = _state
init {
posts()
}
fun posts() {
_state.value = PostListState.Loading
futures.add(repo.posts {
_state.value = it
})
}
// при уничтожении вьюшек мы должны удалить Observer'ы,
// чтобы случайно не дернуть уже мертвые вьюшки
override fun onDestroyViewCalled() {
super.onDestroyViewCalled()
_state.clearChangedListeners()
}
// при полном уничтожении компонента (экрана в нашем случае)
// отменяем все background задачи
override fun onDestroyComponentCalled() {
super.onDestroyComponentCalled()
futures.forEach {
it.cancel(true)
}
}
}Чтобы понять как на самом деле происходит переключение между потоками посмотрим на метод posts() из PostRepository:
// выполнение сетевого запроса на background потоке
fun posts(dataListener: (PostListState) -> Unit) = backgroundRunner.runInBackground {
try {
val response = requester.get(posts_path)
if (response.isSuccessful) {
...
// переключение на главный поток для возвращения результата
uiRunner.runInUi {
dataListener.invoke(PostListState.Success(albumModels))
}
} else {
uiRunner.runInUi {
dataListener.invoke(PostListState.Error)
}
}
} catch (_: Exception) {
uiRunner.runInUi {
dataListener.invoke(PostListState.Error)
}
}
}
...
// выполняет задачи в background'е на фиксированном пуле потоков
class BackgroundRunner {
private val pool = Executors.newFixedThreadPool(2)
fun runInBackground(task: () -> Unit): Future<*> {
return pool.submit(task)
}
}
...
// выполняет задачу на главном потоке (обновление UI)
class UiRunner {
private val handler = Handler(Looper.getMainLooper())
fun runInUi(task: () -> Unit) {
handler.post(task)
}
}Теперь вы готовы сделать окончательный вывод для себя: стоит ли использовать такой шаблон или выкинуть его в черный ящик и забыть как страшный сон.
Заключение
В качестве собственного вывода приведу достоинства и недостатки моего шаблона в соотвествии со своим мнением, у вас они могут отличаться.
- Мой шаблон не является коробочным решением и требует определенных изменений под себя
- Возможны непредвиденные краши и ошибки (от этого никто не застрахован, тем более мои самописные штуки)
- Работа с мнопоточкой осуществляется через callback'и, которые не всем нравятся в связи с переходом на корутины
- Хранение данных вьюмоделек и экранчиков в Application классе имеет свой предел и поэтому такое решение может быть не совсем удачным для больших Pet-проектов, хотя сохранение данных требуется не всегда и поэтому можно дописать логику когда этого не нужно делать
- Навигация более плавная и шустрая, так как анимации и переходы на вьюшках работают быстрее нежели на фрагментах
- Упрощение работы с несохраненным состоянием данных во вьюмодели и удобная передача параметров между экранами
- Гибкая верстка кодом с плавным переключением тем без пересоздания активити
В заключении скажу что мой шаблон скорее подходит людям которые любят извращаться и писать свою всячину, которая делает их счастливыми и довольными кодерами :)