Мой шаблон 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-проектов, хотя сохранение данных требуется не всегда и поэтому можно дописать логику когда этого не нужно делать
- Навигация более плавная и шустрая, так как анимации и переходы на вьюшках работают быстрее нежели на фрагментах
- Упрощение работы с несохраненным состоянием данных во вьюмодели и удобная передача параметров между экранами
- Гибкая верстка кодом с плавным переключением тем без пересоздания активити
В заключении скажу что мой шаблон скорее подходит людям которые любят извращаться и писать свою всячину, которая делает их счастливыми и довольными кодерами :)