Kotlin
March 25, 2023

Мой шаблон Android приложения для Pet-проектов

Приветствую всех любителей покодить)

В течение года разработки небольшого приложения я рефакторил код, что-то менял, удалял, добавлял и пришел к интересной комбинации различных практик и решений, которая впоследствии стала шаблоном для собственных Pet-проектов.

Начну пожалуй с того, чтобы попросить вас не писать комментарии из разряда: "сейчас есть современный стэк, а это все никому не нужно", "ваш код - говнокод", "автор тупо пиарит никому ненужный репозиторий" и так далее. Также если для вас "писать свою навигацию на вьюшках" и "хранить вьюмодель в Application классе" считаются абсурдными практиками, незамедлительно скипайте эту статью.

Ну и собственно поводом написания этой статьи у меня было желание чем-то поделиться с людьми, пусть даже это будет мой незаурядный шаблон.

Почему незаурядный? Приведу несколько особенностей, исходя из которых так можно посчитать (а может нельзя, решайте сами):

  1. Верстка кодом с использованием своих собственных Kotlin Extensions (похожий стиль реализован в таких либах как Anko или Splitties)
  2. Хранение самописных вьюмоделек в Application классе
  3. Навигация на View'ах (есть более крутая либа чем мой вариант такая как Conductor)
  4. Замена LiveData'ы на самописные Observable свойства которые не требуют подписки на жизненный цикл компонента
  5. Выполнение задач на разных потоках с помощью 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)
    }

}

Теперь вы готовы сделать окончательный вывод для себя: стоит ли использовать такой шаблон или выкинуть его в черный ящик и забыть как страшный сон.

Заключение

В качестве собственного вывода приведу достоинства и недостатки моего шаблона в соотвествии со своим мнением, у вас они могут отличаться.

Недостатки:

  1. Мой шаблон не является коробочным решением и требует определенных изменений под себя
  2. Возможны непредвиденные краши и ошибки (от этого никто не застрахован, тем более мои самописные штуки)
  3. Работа с мнопоточкой осуществляется через callback'и, которые не всем нравятся в связи с переходом на корутины
  4. Хранение данных вьюмоделек и экранчиков в Application классе имеет свой предел и поэтому такое решение может быть не совсем удачным для больших Pet-проектов, хотя сохранение данных требуется не всегда и поэтому можно дописать логику когда этого не нужно делать

Достоинства:

  1. Навигация более плавная и шустрая, так как анимации и переходы на вьюшках работают быстрее нежели на фрагментах
  2. Упрощение работы с несохраненным состоянием данных во вьюмодели и удобная передача параметров между экранами
  3. Гибкая верстка кодом с плавным переключением тем без пересоздания активити

В заключении скажу что мой шаблон скорее подходит людям которые любят извращаться и писать свою всячину, которая делает их счастливыми и довольными кодерами :)

Источник