Как я писал супер кастомизированное Android приложение в 2024 году

В начале года у меня появилась прикольная идея: сделать Android-приложение, которое будет показывать анимации для алгоритмов сортировки. Чтобы вы сразу поняли, что представляет из себя приложение, на GitHub есть скрины и короткие видео. Давайте по кусочкам разберём мой проект.

▍ Минимум библиотек, максимум самописных решений


В моём приложении используется всего три Gradle зависимости:

  1. Androidx Core — можно было обойтись без этой зависимости, только пришлось бы немного заморочиться с edge-to-edge и писать дополнительный код, который уже есть в библиотечных классах WindowCompat и ViewCompat.
  2. Kotlin Coroutines — не стал заморачиваться с асинхронными задачами и усложнять код callback'ами, к тому же корутинки одна из любимых моих библиотек, поэтому почему бы не затащить, весит всё равно немного.
  3. Junit 4 — как же без тестирования.

Как видите, нет никакого Jetpack Compose или фрагментов, вообще основной задачей было сделать всё с минимальными зависимостями вплоть до самописных ViewModel и навигации на View, на закусочку вместо xml разметки я писал вёрстку кодом.

▍ Навигация на View


Для навигации на View был написан кастомный Navigator:

/*
parent — родительская View, куда кладутся экраны (дочерние View),
в моём случае это FrameLayout

viewModelProvider — штука, которая используется для создания ViewModel,
используется для сохранения / восстановления состояния при
изменении конфигурации устройства (переворот экрана)

onBackPressedDispatcher — обработчик кнопки "Назад", я тупо скопировал код
этого класса из новых версий Android SDK
*/
class Navigator(
    private val parent: ViewGroup,
    private val viewModelProvider: ViewModelProvider,
    private val onBackPressedDispatcher: OnBackPressedDispatcher
) {

    // список экранов в backstack'е
    private val stack = mutableListOf<NavigationScreen>()
    // список callback'ов для обработки кнопки "Назад"
    private val callbacks = mutableListOf<OnBackPressedCallback>()
    // ViewModelProvider'ы для создания ViewModel
    private val providers = mutableMapOf<String, ViewModelProvider>()

    private val stackId = R.id.navigation_stack

    private val NavigationScreen.viewModelKey: String
        get() = "ViewModelProvider.SubProvider.Key.${this::class.java.canonicalName}"

    // добавляем новый экран (View) и создаём для него ViewModelProvider с 
    // callback'ом если он кладётся в backstack
    fun navigateForward(screen: NavigationScreen, isAddToBackStack: Boolean = true) {
        when {
            isAddToBackStack -> {
                val key = screen.viewModelKey

                /*
                ViewModelProvider построен по принципу дерева (паттерн Компоновщик)
                при навигации на экран создаётся собственный ViewModelProvider,
                который хранит ViewModel'и для этого экрана
                */
                val provider = parentViewModelProvider().createChildProvider(key)
                providers[key] = provider
              
                parent.addView(screen.view(BaseParams(parent.context, this, provider)))
                stack.add(screen)

                // когда добавляем экран в backstack 
                // обязательно создаём callback для кнопки "Назад"
                val callback = createOnBackPressedCallback()
                callbacks.add(callback)
                onBackPressedDispatcher.addCallback(callback)
            }
            else -> {
                parent.addView(screen.view(BaseParams(parent.context, this, viewModelProvider)))
            }
        }
    }

    /*
    тут всё очень просто, либо ничего не делаем, если backstack пустой,
    либо удаляем всё, что возможно: сам экран, ViewModelProvider и 
    View из родителя
    */
    fun navigateBack(): Boolean {
        if (stack.isEmpty()) return false

        val screen = stack.removeLast()
        val key = screen.viewModelKey
        viewModelProvider.removeChildProvider(key)
        providers.remove(key)
        parent.removeLast()
        callbacks.removeLast().changeIsEnabled(false)

        return true
    }

    // восстановление экранов (backstack'а) происходит 
    // через чтение Memory кэша
    fun onRestoreBackStack(cache: MemoryIDIdentityCache) {
        val cachedStack = cache.read<MutableList<NavigationScreen>>(stackId) ?: return
        cache.remove(stackId)

        stack.clear()
        callbacks.clear()

        var index = 0
        while (index < cachedStack.size) {
            navigateForward(cachedStack[index])

            index++
        }
    }

    // список экранов или backstack сохраняется через Memory кэш
    fun onSaveBackStack(cache: MemoryIDIdentityCache) {
        callbacks.forEach { it.changeIsEnabled(false) }

        cache.save(stackId, stack)
    }

    private fun createOnBackPressedCallback() =
        object : OnBackPressedCallback(true) {
            override fun handleOnBackPressed() {
                navigateBack()
            }
        }

    /*
    возвращает либо самый верхнеуровневый ViewModelProvider,
    либо берёт ViewModelProvidr по ключу последнего экрана 
    из backstack'а
    */
    private fun parentViewModelProvider(): ViewModelProvider {
        if (stack.isEmpty()) return viewModelProvider

        val key = stack.last().viewModelKey
        return providers[key] ?: throw IllegalStateException("Not found such a ViewModelProvider with the key: $key")
    }

}

Думаю, вы заметили, что мой навигатор при переходе на новый экран тупо добавляет его View, ничего не делая с View предыдущего экрана, при таком поведении может случиться переполнение памяти, если будет очень глубокий стэк навигации, но для моего приложения это более чем оправдано, так как в нём всего 2-5 экранов.

Теперь разберёмся, что такое NavigationScreen, это всего лишь простой интерфейс, возвращающий View:

fun interface NavigationScreen {
    fun view(params: BaseParams): View
}

// хранит базовые штуки для каждого экрана
class BaseParams(
    val context: Context,
    val navigator: Navigator,
    val viewModelProvider: ViewModelProvider
)

В итоге каждый экран выглядит примерно так:

@SuppressLint("ViewConstructor")
class SortingAlgorithmMainFragment(params: BaseParams) : CoreLinearLayout(params.context) {

    private val navigator = params.navigator
    private val viewModel = params.viewModelProvider.provide(SortingAlgorithmViewModel::class.java) {
        SortingAlgorithmViewModel()
    }
  
    init {
        orientation = VERTICAL
        
        val toolbarView = ToolbarView(context)
        toolbarView.changeMenuButtonDrawable(R.drawable.ic_settings)
        toolbarView.changeMenuClickListener {
            navigator.navigateForward(::SortingAlgorithmSelectionFragment)
        }
        addView(toolbarView)

        ...
    }
    
}

А навигация происходит следующим образом:

// краткая запись
navigator.navigateForward(
    screen = ::SortingAlgorithmMainFragment, 
    isAddToBackStack = false
)

// более развёрнутая запись
navigator.navigateForward(
    screen = { params ->
        SortingAlgorithmMainFragment(params)
    },
    isAddToBackStack = false
)

Простенько и изящно, что ещё добавить.

▍ Реализация Memory кэша


Memory кэш реализован очень просто, во-первых, он лежит в Application классе, чтобы не умереть при изменении конфигурации (переворот экрана) или при смерти процесса:

class App : Application() {

    val cache = MemoryIDIdentityCache()

}

Во-вторых, он построен на структуре данных SparseArray, где ключами являются числа типа Int, для которых я использую Android id:

class MemoryIDIdentityCache {

    private val data = SparseArray<Any>()

    fun save(@IdRes key: Int, value: Any) {
        data[key] = value
    }

    fun <T> read(@IdRes key: Int): T? {
        return data[key] as? T
    }

    fun remove(@IdRes key: Int) {
        data.remove(key)
    }

}

Если не шарите за SparseArray, в моём канале был пост на эту тему.

▍ Реализация ViewModelProvider


Это всего лишь частный случай паттерна «Компоновщик»:

/*
ViewModelProvider является наследником CoreViewModel,
это как раз нужно для построения иерархии ViewModelProvider'ов
напомню, что на каждый экран в backstack'е создаётся свой ViewModelProvider
*/
class ViewModelProvider : CoreViewModel {

    // ViewModel хранится в хэш-таблице
    private val cache = hashMapOf<String, CoreViewModel>()

    // создаёт дочерний ViewModelProvider, если такой уже есть, то вернёт его
    fun createChildProvider(key: String): ViewModelProvider {
        val provider = cache[key]
        if (provider != null && provider is ViewModelProvider) return provider

        val childProvider = ViewModelProvider()
        val childProviderCache = childProvider.cache
        // child ViewModelProviders must have access to parent ViewModels
        cache.forEach { (key, value) ->
            if (value !is ViewModelProvider) {
                childProviderCache[key] = value
            }
        }

        cache[key] = childProvider

        return childProvider
    }

    // удаляет дочерний ViewModelProvider
    fun removeChildProvider(key: String) {
        val provider = cache[key]
        if (provider is ViewModelProvider) {
            provider.cache.clear()
            cache.remove(key)
        }
    }

    // возвращает ViewModel или бросает ошибку, если её нет 
    // в текущем ViewModelProvider'е    
    fun <T : CoreViewModel> provide(viewModelClass: Class<T>): T {
        val key = key(viewModelClass)
        return cache[key] as? T ?: throw IllegalStateException("Not found such a ViewModel with the key: $key")
    }

    // возвращает ViewModel или создаёт её, сохраняя 
    // в текущем ViewModelProvider'е
    fun <T : CoreViewModel> provide(viewModelClass: Class<T>, factory: () -> T): T {
        val key = key(viewModelClass)
        val cachedViewModel = cache[key]
        if (cachedViewModel != null) return cachedViewModel as T

        val viewModel = factory.invoke()
        cache[key] = viewModel
        return viewModel
    }

    private fun <T> key(viewModelClass: Class<T>): String =
        "ViewModelProvider.Key.${viewModelClass.canonicalName}"

}

Алгоритм следующий:

  1. Создаётся корневой ViewModelProvider для самого первого экрана.
  2. Самый первый экран создаёт ViewModel и кладёт её в корневой ViewModelProvider.
  3. Пользователь нажимает кнопку на первом экране и переходит на второй.
  4. Создаётся дочерний ViewModelProvider для второго экрана.
  5. Второй экран создаёт ViewModel и кладёт её в дочерний ViewModelProvider.
  6. При переходе на третий экран создаётся дочерний ViewModelProvider для предыдущего и т. д.

В итоге получаем некоторое дерево из ViewModelProvider'ов, в которых хранятся ViewModel'и экранов.

Чтобы при изменении конфигурации не потерять состояния ViewModel'ей корневой ViewModelProvider сохраняется через onRetainNonConfigurationInstance() метод:

class MainActivity : Activity() {

    private var viewModelProvider by Delegates.notNull<ViewModelProvider>()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // создаём корневой ViewModelProvider или получаем его
        // если он ранее был уже сохранён
        viewModelProvider = lastNonConfigurationInstance as? ViewModelProvider ?: ViewModelProvider()
    }

    // сохраняем корневой ViewModelProvider
    override fun onRetainNonConfigurationInstance() = viewModelProvider

}

Также я написал Junit тесты для логики ViewModelProvider'а, если интересно можете глянуть в исходниках и запустить.

▍ Реализация темы


Вся вёрстка в проекте сделана кодом без использования xml файлов и библиотек, таких как Jetpack Compose:

class MainActivity : Activity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val fragmentContainerView = CoreFrameLayout(this)
        fragmentContainerView.layoutParams(viewGroupLayoutParams().match())
        setContentView(fragmentContainerView)

        ...

    }

}

Так как xml вёрстки нет, то и поддержка темы реализована через самописное решение, а точнее через кастомизированные наследники для каждой View:

open class CoreFrameLayout @JvmOverloads constructor(
    ctx: Context,
    // цвета и shape указываются через аттрибуты темы
    private val backgroundColor: ColorAttributes = ColorAttributes.primaryBackgroundColor,
    private val shape: ShapeAttribute = ShapeAttribute.medium,
    private val shapeTreatmentStrategy: ShapeTreatmentStrategy = ShapeTreatmentStrategy.None()
): FrameLayout(ctx), ThemeManager.ThemeManagerListener {

    /*
    переопределённый метод из ThemeManager.ThemeManagerListener интерфейса, 
    принимает на вход тему и WindowInsets (поддержка edge-to-edge), 
    если последние были изменены
    */
    override fun onThemeChanged(insets: ThemeManager.WindowInsets, theme: CoreTheme) {
        val gradientDrawable = GradientDrawable()
        /*
        shapeTreatmentStrategy возвращает FloatArray со значениями для каждого угла,
        могут быть разные стратегии, например, только закруглённые верхние углы или
        наоборот, только нижние и тд.

        theme.shapeStyle[shape] возвращает Float значение для радиуса закругления
        
        context.dp() принимает эквивалент в dp единицах, возвращает в пикселях
        */
        gradientDrawable.cornerRadii = shapeTreatmentStrategy.floatArrayOf(context.dp(theme.shapeStyle[shape]))
        // возвращает конкретный цвет для текущей темы
        gradientDrawable.setColor(theme.colors[backgroundColor])
        background = gradientDrawable
    }

    // когда View присоединена к Window, она подписывается на изменения темы
    override fun onAttachedToWindow() {
        super.onAttachedToWindow()
        ThemeManager.addThemeListener(this)
    }

    // когда View отсоединена от Window, она отписывается от изменений темы
    override fun onDetachedFromWindow() {
        super.onDetachedFromWindow()
        ThemeManager.removeThemeListener(this)
    }

}

Всё достаточно просто: подписываемся на изменение темы, когда View актуальна и отписываемся, когда неактуальна через специальный класс ThemeManager, последний всего лишь хранит callback'и и оповещает иерархию View, когда тема или WindowInsets поменялись (паттерн Наблюдатель):

/*
object означает, что объект является синглетоном, поэтому 
очень важно отписываться для View от callback'ов, чтобы
не произошла ситуация, когда View была уничтожена, а 
ThemeManager хранит неактуальный callback
*/
object ThemeManager {

    private var theme = CoreTheme.LIGHT
    private var insets: WindowInsets = WindowInsets(0, 0, 0, 0)
    // набор callback'ов, используется множество, чтобы не было дубликатов
    private val themeListeners = mutableSetOf<ThemeManagerListener>()

    // когда View актуальна (onAttachedToWindow) callback добавляется
    fun addThemeListener(listener: ThemeManagerListener) {
        themeListeners.add(listener)
        listener.notify()
    }

    // когда View неактуальна (onDetachedFromWindow) callback удаляется
    fun removeThemeListener(listener: ThemeManagerListener) {
        themeListeners.remove(listener)
        listener.notify()
    }

    // при изменении темы все callback'и оповещаются
    fun changeTheme(newTheme: CoreTheme) {
        if (theme == newTheme) return

        theme = newTheme
        themeListeners.notifyAll()
    }

    // при изменении WindowInsets все callback'и оповещаются
    fun changeInsets(newInsets: WindowInsets) {
        if (insets == newInsets) return
      
        insets = newInsets
        themeListeners.notifyAll()
    }

    private fun Set<ThemeManagerListener>.notifyAll() {
        forEach { listener -> listener.notify() }
    }

    // при оповещении передаются актуальные параметры для insets и theme
    private fun ThemeManagerListener.notify() {
        onThemeChanged(insets, theme)
    }

    fun interface ThemeManagerListener {
        fun onThemeChanged(insets: WindowInsets, theme: CoreTheme)
    }

    data class WindowInsets(
        val start: Int,
        val top: Int,
        val end: Int,
        val bottom: Int
    )

}

Всё предельно просто:

  1. Создаётся View и подписывается на изменения, ThemeManager сохраняет у себя callback.
  2. Меняется тема или WindowInsets, ThemeManager пробегается по всем callback'ам и передаёт актуальные данные, иерархия View получает эти изменения.
  3. При уничтожении View отписывается от изменений, а ThemeManager удаляет у себя callback.

Теперь поговорим о такой штуке как аттрибуты, название, кстати, взято из Android xml тем, вообще, если вы делали кастомные темы в Jetpack Compose, то знаете, что нет необходимости придумывать что-то похожее, можно просто обратиться к конкретной переменной:

Text(
    color = CustomTheme.colors.primaryTextColor,
    text = "..."
)

В случае с View не совсем так, в отличие от Jetpack Compose, где при изменении темы происходит перерисовка дерева с актуальными параметрами, в иерархии View такого нет и поэтому остаётся либо вариант, когда мы всегда конфигурируем View:

val textView1 = TextView(context)
textView.setTextColor(theme.colors.primaryTextColor)
textView.background = ...
textView.typeface = ...

val textView2 = TextView(context)
textView.setTextColor(theme.colors.primaryTextColor)
textView.background = ...
textView.typeface = ...

val textView3 = TextView(context)
textView.setTextColor(theme.colors.primaryTextColor)
textView.background = ...
textView.typeface = ...

Либо используем аттрибуты и особо не думаем о каждом параметре:

val textView1 = CoreTextView(
    ctx = context,
    textColor = ColorAttributes.primaryTextColor,
    textStyle = TypefaceAttribute.Body1
)

// primaryTextColor is default
val textView2 = CoreTextView(
    ctx = context,
    textStyle = TypefaceAttribute.Body2
)

// primaryTextColor is default
val textView3 = CoreTextView(
    ctx = context,
    textStyle = TypefaceAttribute.Body3
)

У каждого варианта есть свои нюансы, в первом всё равно нужно будет где-то подписываться на изменения темы и брать актуальные значения, что породит ещё больше шаблонного кода, во втором всё инкапсулировано в конкретные наследники View, но приходится добавлять аттрибуты.

Почему нельзя поместить всё в пределах View и не использовать аттрибуты? Всё просто, вы не сможете поменять параметры в таком случае:

open class CoreTextView @JvmOverloads constructor(
    ctx: Context,
    private val textColor: ColorAttributes = ColorAttributes.primaryTextColor,
    ...
): TextView(ctx), ThemeManager.ThemeManagerListener {

    override fun onThemeChanged(insets: ThemeManager.WindowInsets, theme: CoreTheme) {
        ...
        // нельзя поменять цвет текста, например на primaryColor,
        // так как цвет текста завязан на primaryTextColor
        setTextColor(theme.colors.primaryTextColor)

        // с аттрибутами можно, достаточно передать другой аттрибут
        setTextColor(theme.colors[textColor])
    }

    ...

}

Есть, конечно, гибридный способ решить проблему — создать отдельные наследники на каждый параметр:

class PrimaryTextColorTextView @JvmOverloads constructor(
    ctx: Context
): TextView(ctx), ThemeManager.ThemeManagerListener {

    override fun onThemeChanged(insets: ThemeManager.WindowInsets, theme: CoreTheme) {
        setTextColor(theme.colors.primaryTextColor)
    }

}

class PrimaryColorTextView @JvmOverloads constructor(
    ctx: Context
): TextView(ctx), ThemeManager.ThemeManagerListener {

    override fun onThemeChanged(insets: ThemeManager.WindowInsets, theme: CoreTheme) {
        setTextColor(theme.colors.primaryColor)
    }

}

class SecondaryColorTextView @JvmOverloads constructor(
    ctx: Context
): TextView(ctx), ThemeManager.ThemeManagerListener {

    override fun onThemeChanged(insets: ThemeManager.WindowInsets, theme: CoreTheme) {
        setTextColor(theme.colors.secondaryColor)
    }

}

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

Давайте глянем, как под капотом устроены эти самые аттрибуты:

// есть список аттрибутов
enum class ColorAttributes {
    primaryColor,
    primaryDarkColor,
    colorOnPrimary,
    primaryBackgroundColor,
    secondaryBackgroundColor,
    disabledBackgroundColor,
    selectableBackgroundColor,
    primaryTextColor,
    transparent
}

// есть конкретные цвета, которые прописываются в каждой теме
class Colors(
    private val primaryColor: Int = CoreColors.greenMedium,
    private val primaryDarkColor: Int = CoreColors.greenDark,
    private val colorOnPrimary: Int = CoreColors.white,
    private val primaryBackgroundColor: Int,
    private val secondaryBackgroundColor: Int,
    private val disabledBackgroundColor: Int,
    private val primaryTextColor: Int,
    private val selectableBackgroundColor: Int
) {

    // возвращает определённый цвет темы по аттрибуту (у каждой темы свои значения цветов)
    operator fun get(type: ColorAttributes): Int {
        return when(type) {
            ColorAttributes.primaryColor -> primaryColor
            ColorAttributes.primaryDarkColor -> primaryDarkColor
            ColorAttributes.colorOnPrimary -> colorOnPrimary
            ColorAttributes.primaryBackgroundColor -> primaryBackgroundColor
            ColorAttributes.secondaryBackgroundColor -> secondaryBackgroundColor
            ColorAttributes.disabledBackgroundColor -> disabledBackgroundColor
            ColorAttributes.primaryTextColor -> primaryTextColor
            ColorAttributes.selectableBackgroundColor -> selectableBackgroundColor
            ColorAttributes.transparent -> CoreColors.transparent
        }
    }

}

open class CoreFrameLayout @JvmOverloads constructor(
    ctx: Context,
    private val backgroundColor: ColorAttributes = ColorAttributes.primaryBackgroundColor,
    ...
): FrameLayout(ctx), ThemeManager.ThemeManagerListener {

    override fun onThemeChanged(insets: ThemeManager.WindowInsets, theme: CoreTheme) {
        val gradientDrawable = GradientDrawable()
        /*
        берём актуальный цвет для текущей темы по аттрибуту,
        если нужно поменять цвет передаём другой аттрибут
        */
        gradientDrawable.setColor(theme.colors[backgroundColor])
        background = gradientDrawable
    }

    ...
    
}

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

Ну и, собственно, объявление конкретных цветов для конкретных тем выглядит следующим образом:

enum class CoreTheme(
    ...   
    val colors: Colors
) {

    // для конкретной темы прописываются конкретные цвета
    LIGHT(
        colors = Colors(
            primaryBackgroundColor = CoreColors.white,
            secondaryBackgroundColor = CoreColors.white,
            disabledBackgroundColor = CoreColors.grayMedium,
            primaryTextColor = CoreColors.black,
            selectableBackgroundColor = CoreColors.grayLight
        )
    ),

    DARK(
        colors = Colors(
            primaryBackgroundColor = CoreColors.black,
            secondaryBackgroundColor = CoreColors.grayBold,
            disabledBackgroundColor = CoreColors.grayMedium,
            primaryTextColor = CoreColors.white,
            selectableBackgroundColor = CoreColors.grayLight
        )
    )

}

Вроде бы разобрались с аттрибутами, идём дальше.

С цветами и радиусом закругления проблем особо нет, это всего лишь Int и Float значения, а вот со шрифтами посложнее, так как они связаны с Android SDK:

// чтобы создать шрифт нужен Context
val robotoLight = Typeface.createFromAsset(context.assets, "roboto_light.ttf")
val robotoRegular = Typeface.createFromAsset(context.assets, "roboto_regular.ttf")
val robotoBold = Typeface.createFromAsset(context.assets, "roboto_bold.ttf")

Для работы со шрифтами я решил создать вспомогательный класс:

object TypefaceManager {

    // при запуске MainActivity создаётся слабая ссылка на AssetManager
    private var assetManagerReference: WeakReference<AssetManager>? = null
    // шрифты создаются только один раз, после этого кэшируются в хэш-таблице
    private val typefaces = hashMapOf<String, Typeface>()

    fun setAssets(assetManager: AssetManager) {
        assetManagerReference = WeakReference(assetManager)
    }

    // создаёт шрифт с помощью AssetManager и кэширует его
    // если шрифт уже был закэширован, то возвращает его
    fun typeface(weight: TypefaceWeight): Typeface {
        val path = weight.assetPath
        val savedTypeface = typefaces[path]
        if (savedTypeface != null) {
            return savedTypeface
        }

        val assetManager = assetManagerReference?.get() 
            ?: throw IllegalStateException("assetManager is null, first call setAssets")
        val typeface = Typeface.createFromAsset(assetManager, path)
        typefaces[path] = typeface
        typeface
    }

}

// шрифты лежат в assets папке Android приложения
enum class TypefaceWeight(val assetPath: String) {
    LIGHT("sf_pro_rounded_light.ttf"),
    REGULAR("sf_pro_rounded_regular.ttf"),
    MEDIUM("sf_pro_rounded_medium.ttf"),
    SEMI_BOLD("sf_pro_rounded_semibold.ttf"),
    BOLD("sf_pro_rounded_bold.ttf")
}

Чтобы не произошло краша из-за пустой ссылки на AssetManager, надо добавить следующий код в MainActivity:

class MainActivity : Activity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        TypefaceManager.setAssets(assets)

        ...

    }

}

Глянем, как TypefaceManager используется в кастомизированной версии TextView:

open class CoreTextView @JvmOverloads constructor(
    ctx: Context,
    private val textColor: ColorAttributes = ColorAttributes.primaryTextColor,
    private val textStyle: TypefaceAttribute = TypefaceAttribute.Body1
): TextView(ctx), ThemeManager.ThemeManagerListener {

    override fun onThemeChanged(insets: ThemeManager.WindowInsets, theme: CoreTheme) {
        // fontFamily это TypefaceWeight, а textSize — обыкновенное Float значение
        val (fontFamily, textSize) = theme.textStyle[textStyle]
        // создаём или получаем шрифт, если он был закэширован
        typeface = TypefaceManager.typeface(fontFamily)
        // fontSize это Kotlin Extension, устанавливает размер шрифта в sp единицах
        fontSize(textSize)
        setTextColor(theme.colors[textColor])
    }

    ...

}

Как видите, стиль текста хранит не только TypefaceWeight, но и ещё размер, под капотом это выглядит так:

class TypefaceStyle(
    private val title1: Pair<TypefaceWeight, Float>,
    private val title2: Pair<TypefaceWeight, Float>,
    private val body1: Pair<TypefaceWeight, Float>,
    private val body2: Pair<TypefaceWeight, Float>,
    private val caption1: Pair<TypefaceWeight, Float>
) {

    // также как цвет получаем стиль текста по аттрибуту
    operator fun get(attr: TypefaceAttribute): Pair<TypefacePath, Float> {
        return when (attr) {
            TypefaceAttribute.Title1 -> title1
            TypefaceAttribute.Title2 -> title2
            TypefaceAttribute.Body1 -> body1
            TypefaceAttribute.Body2 -> body2
            TypefaceAttribute.Caption1 -> caption1
        }
    }

}

// аттрибуты для стилей текста
enum class TypefaceAttribute {
    Title1,
    Title2,
    Body1,
    Body2,
    Caption1
}

Знакомая картина, не правда ли? (аттрибуты для цветов реализованы по такой же схеме)

Также я написал дополнительные расширения для работы с ViewGroup.LayoutParams и другими штуками, связанными с View, чтобы верстать кодом было намного проще и удобнее:

val closeView = CoreImageButtonView(
    ctx = context,
    shape = ShapeAttribute.medium,
    shapeTreatmentStrategy = ShapeTreatmentStrategy.StartBottomTopEndRounded()
)
closeView.setOnClickListener { viewModel.navigateBack() }
// Kotlin Extension для установки внутренних отступов
closeView.padding(context.dp(12))
closeView.setImageResource(R.drawable.ic_close)
// удобный builder в стиле Kotlin'а для создания LayoutParams
closeView.layoutParams(frameLayoutParams()
    .width(context.dp(48))
    .height(context.dp(48))
    .gravity(Gravity.END))
titleContentView.addView(closeView)

Остальные расширения и прикольные фишки можете найти в исходниках.

▍ Реализация MVI на битовых масках


Основная идея MVI состоит в едином неизменяемом состоянии, для такого архитектурного подхода существует проблема в перерисовки UI в случае больших состояний, поэтому надо научиться отрисовывать состояние по кусочкам.

Посмотрим, как выглядит отрисовка состояния в моём проекте:

// переменная для кэширования предыдущего состояния
var cachedState = SortingAlgorithmState()
coroutineScope.launch {
    viewModel.state.collect {

        /*
        метод difference() принимает на вход предыдущее состояние 
        и возвращает новое с битовой маской, которая используется 
        для определения того, что было изменено
        */
        val state = it.difference(cachedState); cachedState = it

        // если был изменён текущий алгоритм сортировки, то
        // установливаем новые текстовки
        if (state.hasChanged(SortingAlgorithmState.selectedAlgorithmChanged)) {
            with(state.selectedAlgorithm) {
                toolbarView.changeTitle(context.getString(title))

                worstTimeComplexityView.changeDescription(worstTimeComplexity)
                bestTimeComplexityView.changeDescription(bestTimeComplexity)
                averageTimeComplexityView.changeDescription(averageTimeComplexity)
                worstSpaceComplexityView.changeDescription(worstSpaceComplexity)
            }
        }

        // если было изменено состояние кнопки "Старт", меняем иконку 
        // и обработчик нажатия
        if (state.hasChanged(SortingAlgorithmState.buttonStateChanged)) {
            val (imageResource, clickListener) = when (state.buttonState) {
                SortingAnimationButtonState.NONE,
                SortingAnimationButtonState.PAUSED -> R.drawable.ic_play to OnClickListener {
                    sortingAlgorithmView.startAnimation()
                    viewModel.toggleAnimation()
                }
                SortingAnimationButtonState.RUNNING -> R.drawable.ic_pause to OnClickListener {
                    sortingAlgorithmView.pauseAnimation()
                    viewModel.toggleAnimation()
                }
            }

            playPauseButtonView.setImageResource(imageResource)
            playPauseButtonView.setOnClickListener(clickListener)
        }

        // если был изменён массив для сортировки, меняем его
        if (state.hasChanged(SortingAlgorithmState.sortingArrayChanged)) {
            sortingAlgorithmView.changeArray(state.sortingArray)
        }

        // если был изменён массив для сортировки или алгоритм сортировки, 
        // меняем шаги анимации
        if (state.hasChanged(SortingAlgorithmState.selectedAlgorithmChanged)
            or state.hasChanged(SortingAlgorithmState.sortingArrayChanged)) {
            sortingAlgorithmView.changeAnimationSteps(state.steps(resources))
        }

    }
}

Алгоритм достаточно простой:

  1. Берём новое и старое состояния, сравниваем их c помощью метода SortingAlgorithmState.difference().
  2. Получаем новое состояние с битовой маской, в которой указано всё, что было изменено.
  3. Проверяем, что было изменено, и обновляем UI

Посмотрим, как под капотом устроен SortingAlgorithmState:

class SortingAlgorithmState(
    val selectedAlgorithm: SortingAlgorithm = EmptyAlgorithm(),
    val buttonState: SortingAnimationButtonState = SortingAnimationButtonState.NONE,
    val sortingArray: IntArray = intArrayOf(),
    // битовая маска, если все биты равны нулю, значит, ничего не изменилось 
    private val compared: Int = 7 // low byte: 00000111
) {

    // если указанный бит (pieceState) равен единице, значит 
    // состояние было изменено
    fun hasChanged(pieceState: Int): Boolean = 
        (compared and pieceState) == pieceState

    /*
    сравнивает текущее состояние с указанным (other) 
    и возвращает новое состояние с обновлённой битовой маской,
    в которой указано всё, что было изменено
    */
    fun difference(other: SortingAlgorithmState): SortingAlgorithmState {
        var compared = 0

        // если был изменён текущий алгоритм сортировки, устанавливаем бит
        if (selectedAlgorithm != other.selectedAlgorithm) {
            compared = compared or selectedAlgorithmChanged
        }

        // если поменялось состояние кнопки, устанавливаем бит
        if (buttonState != other.buttonState) {
            compared = compared or buttonStateChanged
        }

        // если поменялся массив для сортировки, устанавливаем бит
        if (!sortingArray.contentEquals(other.sortingArray)) {
            compared = compared or sortingArrayChanged
        }

        return SortingAlgorithmState(selectedAlgorithm, buttonState, sortingArray, compared)
    }

    fun steps(resources: Resources): List<SortingAlgorithmStep> {
        return selectedAlgorithm.sort(sortingArray.copyOf(), resources)
    }

    fun changedWith(selectedAlgorithm: SortingAlgorithm) =
        SortingAlgorithmState(selectedAlgorithm, buttonState, sortingArray)

    fun changedWith(buttonState: SortingAnimationButtonState) =
        SortingAlgorithmState(selectedAlgorithm, buttonState, sortingArray)

    fun changedWith(sortingArray: IntArray) =
        SortingAlgorithmState(selectedAlgorithm, buttonState, sortingArray)

    companion object {
        // константы для определения соответствующего бита
        const val selectedAlgorithmChanged: Int = 1 // low byte: 00000001
        const val buttonStateChanged: Int = 2 // low byte: 00000010
        const val sortingArrayChanged: Int = 4 // low byte: 00000100
    }
}

Давайте пошагово разберём, как это работает:

  1. У нас есть сложный экран с кучей кнопок, изображений и текста, сейчас он находится в состоянии X.
  2. Нажимаем кнопку, неважно какую, выполняется какая-то логика и обновляется состояние, теперь оно Y.
  3. Так как состояние было изменено, надо обновить UI, но мы не можем обновить всё без раздумий, это не очень производительно, поэтому надо обновить то, что было изменено, для этого сравниваем состояние X и состояние Y, на основе сравнения устанавливаем биты для тех штук, которые были изменены.
  4. Когда нужно применить состояние в UI, смотрим, что было изменено, и обновляем по кусочкам.

Вот и всё, немного битовой арифметики и ничего сложного, кстати, в Jetpack Compose тоже есть похожий механизм, только он скрыт под капотом.

▍ Заключение


К сожалению, я не смог добраться до кастомной View, которая рисует анимации алгоритмов сортировки, вообще, основной целью статьи изначально было рассмотреть, как устроена эта самая вьюшка, но так уж получилось, что я начал рассматривать другие части проекта, и статья разрослась прилично, поэтому, если текущий материал зайдёт, скорее всего, начеркаю вторую часть…

Полезные ссылки:

  1. Мой телеграм канал.
  2. Github проекта
  3. Другие статьи.

Пишите в комментах ваше мнение и всем хорошего кода!

© 2024 ООО «МТ ФИНАНС»

Telegram-канал со скидками, розыгрышами призов и новостями IT ?

Комментарии (0)