Самодельный «сахар» для Android проекта или «Как делать нельзя»

    Эта статья — набор небольших кубиков сахара для android-проекта, до которых я дошел в свое время и что мне пригодилось. Некоторые вещи, возможно, не будут идеальными решениями, но могут пригодиться вам так же, как в свое время пригодились и мне.

    Application и Toast


    Первое, что всегда может пригодиться и бывает нужно в любой точке программы — ссылка на Application. Это решается простым классом, ссылка на который прописывается в AndroidManifest.

    class App : Application() {
      
       init {
          APP = this
       }
     
       companion object {
          lateinit var APP: App
       }
    }

    Благодаря этому всегда есть доступ к контексту всего приложения и можно получить строки/ресурсы из любого места. И как минимум это нужно для следующей крупицы сахара:

    fun Toast(messageId: Int) {
       Toast.makeText(App.APP, messageId, Toast.LENGTH_LONG).show()
    }
    
    fun Toast(message: String) {
       Toast.makeText(App.APP, message, Toast.LENGTH_LONG).show()
    }

    Мелочь, но благодаря Kotlin и тому, что у нас есть доступ к контексту — теперь из любого места приложения можно вызвать Toast коротко и лаконично. Для того, чтобы метод был доступен везде, его можно разметить в файле без указания корневого класса.

    Кто на экране?


    Сейчас набирают популярность программы с одним Activity. Но для нашей архитектуры было решено использовать несколько Activity. Как минимум для разделения авторизационной логики и основной части приложения. Со временем появилась необходимость понимать, виден ли экран и какая это часть приложения. А в дальнейшем это понадобилось еще и для получения строк в локали приложения. Но обо всем по порядку. Чтобы наше значение APP не скучало в одиночестве, компанию ему составят:

    var screenActivity: AppCompatActivity? = null
    var onScreen: Boolean = false

    А дальше, мы создаем свой базовый класс, наследуемый от AppCompatActivity:

    open class BaseActivity : AppCompatActivity() {
       override fun onStart() {
          super.onStart()
          App.onScreen = true
       }
    
       override fun onStop() {
          super.onStop()
          if (App.screenActivity == this) {
             App.onScreen = false
             if (isFinishing())
                App.screenActivity = null
          }
       }
    
       override fun onDestroy() {
          super.onDestroy()
          if (App.screenActivity == this)
             App.screenActivity = null
       }
    
       override fun onCreate(savedInstanceState: Bundle?) {
          App.screenActivity = this
       }
    
       override fun onRestart() {
          super.onRestart()
          App.screenActivity = this
       }
    
    }

    Да, по новым гайдам можно в необходимых местах подписаться на Lifecycle у Activity. Но имеем что имеем.

    Localized strings


    Бывают функционалы сомнительной полезности, но ТЗ есть ТЗ. К такому функционалу я бы отнес выбор языка в приложении взамен системному. Уже давно есть код, который позволяет программно подменять язык. Но мы столкнулись с одним багом, который возможно повторяется только у нас. Суть бага в том, что если брать строку через контекст приложения, а не через контекст Activity — то строка возвращается в локали системы. И не всегда удобно прокидывать контекст Activity. На помощь пришли следующие методы:

    fun getRes(): Resources = screenActivity?.resources ?: APP.resources
    
    fun getLocalizedString(stringId: Int): String = getRes().getString(stringId)
    
    fun getLocalizedString(stringId: Int, vararg formatArgs: Any?): String = getRes().getString(stringId, *formatArgs)

    И теперь из любого места приложения мы можем получить строку в правильной локали.

    SharedPreferences


    Как и во всех приложениях, в нашем приходится хранить некоторые настройки в SharedPreferences. И для упрощения жизни был придуман класс, который скрывает в себе немного логики. Для начала был добавлен для переменной APP новый друг:

    lateinit var settings: SharedPreferences
    

    Он инициализируется при запуске приложения и всегда доступен нам.

    class PreferenceString(val key: String, val def: String = "", val store: SharedPreferences = App.settings, val listener: ModifyListener? = null) {
       var value: String
          get() {
             listener?.customGet()
             return store.getString(key, def) ?: def
          }
          set(value) {
             store.edit().putString(key, value).apply()
             listener?.customSet()
          }
    }
    
    interface ModifyListener {
       fun customGet() {}
    
       fun customSet() {}
    }
    

    Кончено придется генерировать такой класс для каждого типа переменных, но за то можно замести singleton со всеми нужными настройками, например:

    val PREF_LANGUAGE = PreferenceString("pref_language", "ru")

    И теперь всегда можно обратиться к настройкам как к полю, а загрузка/сохранение и связь через listener будут скрыты.

    Orientation и Tablet


    Вам приходилось поддерживать обе ориентации? А как вы определяли в какой вы ориентации сейчас? У нас для этого есть удобный метод, который опять же можно вызвать из любого места и не заботиться о контексте:

    fun isLandscape(): Boolean {
       return getRes().configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
    }

    Если завести в values/dimen.xml:

    <bool name="isTablet">false</bool>

    А в values-large/dimen.xml:

    <bool name="isTablet">true</bool>

    То можно создать еще и метод:

    fun isTablet(): Boolean {
       return getRes().getBoolean(R.bool.isTablet)
    }
    

    DateFormat


    Многопоточность. Порой это страшное слово. Как то раз мы поймали очень странный баг, когда формировали строки из дат в фоне. Оказалось, что SimpleDateFormat не потоко-безопасен. Поэтому было рождено следующее:

    class ThreadSafeDateFormat(var pattern: String, val isUTC: Boolean = false, val locale: Locale = DEFAULT_LOCALE){
       val dateFormatThreadLocal = object : ThreadLocal<SimpleDateFormat>(){
          override fun initialValue(): SimpleDateFormat? {
             return SimpleDateFormat(pattern, locale)
          }
       }
    
       val formatter: SimpleDateFormat
          get() {
             val dateFormat = dateFormatThreadLocal.get() ?: SimpleDateFormat(pattern, locale)
             dateFormat.timeZone = if (isUTC) TimeZone.getTimeZone("UTC") else timeZone
             return dateFormat
          }
    }
    

    И пример использования (да, это опять используется внутри синглтона):

    private val utcDateSendSafeFormat = ThreadSafeDateFormat("yyyy-MM-dd", true)
    val utcDateSendFormat: SimpleDateFormat
       get() = utcDateSendSafeFormat.formatter
    

    Для всего приложения ничего не изменилось, а проблема с потоками решена.

    TextWatcher


    А вас никогда не напрягало, что если тебе надо отловить какой текст вводится в EditText, то надо использовать TextWatcher и реализовывать 3(!) метода. Не критично, но не удобно. А все решается классом:

    open class TextWatcherObject : TextWatcher{
    
       override fun afterTextChanged(p0: Editable?) {}
    
       override fun beforeTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {}
    
       override fun onTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {}
    }
    

    Keyboard


    То, что всегда нужно. То надо сразу показать клавиатуру, то в какой-то момент надо ее скрыть. И тогда нужны следующие два метода. Во-втором случае необходимо передавать корневую view.

    fun showKeyboard(view: EditText){
        view.requestFocus();
        (App.APP.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager?)
                ?.toggleSoftInput(InputMethodManager.SHOW_FORCED, InputMethodManager.HIDE_IMPLICIT_ONLY)
    }
    
    fun hideKeyboardFrom(view: View) {
        (App.APP.getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager?)
                ?.hideSoftInputFromWindow(view.windowToken, 0)
    }

    И, может кому пригодится, функция для копирования любой строки в clipboard с показом toast:

    fun String.toClipboard(toast: Int) {
        val clip = ClipData.newPlainText(this, this)
        (App.APP.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager?)?.setPrimaryClip(clip)
        Toast(toast)
    }

    RecyclerView и TableLayout


    А в завершение этой маленькой статьи, хочу поделиться тем, что недавно мне пришлось решать. Может кому пригодится.

    Исходные данные такие:

    1. 1к+ данных для отображения.
    2. Каждая “ячейка” состоит из примерно 10 полей.
    3. необходимо отлавливать свайп, click, doubleClick, longClick.
    4. и…. данные обновляются каждые 300 — 500 миллисекунд.

    Если забыть про первый пункт. то наиболее рабочее решение — TableLayout. Почему не RecyclerView? А из-за 3 и 4 пунктов. Внутри листа есть оптимизации и он переиспользует view, но не всегда. И в момент создания новой view обработчики касаний не существуют. И ладно, если бы это влияло только на свайпы, но периодически проблема воспроизводится и с обычным тапом. Не помогает даже обновление данных напрямую в View, а не через notify. Поэтому было решено использовать TableLayout. И все было прекрасно, пока данных было не больше 100. А дальше — добро пожаловать в мир зависаний.

    Я видел 2 пути решения — или учить TableLayout переиспользовать ячейки и делать магию при скролле. Или постараться подружить RecyclerView и частое обновление. И я пошел по второму пути. Так как касания и свайпы (в большей мере из-за свайпов) обрабатывались самописным классом на основе View.OnTouchListener, то действенным решением оказалось вынести обработку касаний на уровень RecyclerView, переопределив метод dispatchTouchEvent.

    Алгоритм прост:

    • ловим касание
    • определяем в какой child касание летит с помощью findChildViewUnder(x, y)
    • получаем от LayoutManager позицию элемента
    • если это MotionEvent.ACTION_MOVE, то проверяем с той же позицией мы работаем что и раньше или нет
    • выполняем заложенную логику для касания

    Возможно в будущем еще будут проблемы от этого способа, но на данный момент все работает и это хорошо.
    Поделиться публикацией
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 34

      +1
      Я бы сделал toast (и большинство ваших функций) как фунцию-расширение для Context, на мой взгляд решение с синглтоном App спорное.
      TextWatcherObject не обязательно должен быть классом, в Kotlin доступны интерфейсы с реализацией методов по умолчанию.
      SharedPreferences я бы сделал в виде следующего делегата (для всех типов, а не только для строки)
        0
        1) Toast. К сожалению context есть не везде. Поэтому я и перешел к варианту доступному везде и всегда.
        2) TextWatcherObject. Согласен, можно сделать и интерфейсом.
        3) SharedPreferences. Интересное решение. Спасибо, испробую на досуге.
          0
          А что за кейсы, когда нужен toast но нет context?
            0
            Утилитные классы/интерфейсы, которым не нужен контекст, но в случае чего они должны сообщить о проблеме или успехе
              +2
              В такие классы можно добавить:

              var onError = () -> Unit
              var onSuccess = () -> Unit
              

              и освободить их от работы с UI
          0
          По поводу вашего делегата для SharedPreferences. Как я понял, у него нельзя задать default по умолчанию. И как можно в процессе работы поменять значение preferences внутри? Пока приходит на ум только оборачивание в класс, в который при инициализации передавать SharedPreferences. И тогда пересоздавать этот контейнер.
            0
            Сильно упрощенный пример использования:
               // "за кадром" получаем ссылку на SharedPreference ( скорее всего инжектим)
                var myValue: String by Preference(
                    preference,
                    "KEY", // понятное дело, в реальном коде здесь будет ссылка на константу
                    "DEFAULT_VALUE"
                )
            
               // кладем новое значение в shared pref
              myValue = "NEW_VALUE"
            
              0
              Пояснение по вопросу. Как поменять хранилище значений? Поменять значение с типом SharedPreferences. И я про default, который можно определить в самом конструкторе, чтобы каждый раз не передавать «константу»
                0
                Ну про default лучший вариант сделать:

                //предварительно сделав Preference open
                class IntPreference(
                    private val preferences: SharedPreferences,
                    private val name: String
                ) : Preference<Int>(preferences, name, 0)
                

                а вот «поменять хранилище значений» я честно не знаю (да и не могу придумать зачем)
                  0
                  Настройки, которые зависят от id пользователя. Чтобы не модифицировать ключи, можно перегрузить хранилище и продолжить работать. При этом все настройки хранятся в одном менеджере, независимо от того, зависят ли они от пользователя или нет, чтобы не размазывать по проекту
          0
          Что за жесть, 2020 год был не за горами, на хабре постили, как из аппликейшна сделать синглтон…
          Не статья, а набор вредных советов. Особенно порадовало «Но имеем что имеем». Действительно, и так уже утомились, зачем ещё в чём-то разбираться.
            0
            Да-да. Костыль, костыль, костыль в каждом предложении читается. Не в обиду автору, все развиваются и это нормально, но писать про такое не нужно
            –1
            Правильнее было бы назвать эту статью — «Как делать нельзя»
              0
              Аргументируйте.
              –1

              Сборник костылей и вредных советов. Где-то в углу заплакали паттерны и чистая архитектура

                0
                Зачем хранить ссылку на activity в app, если можно там просто держать имя класса?
                  0
                  Для возможности получить доступ из любой точки по необходимости. А так же для методов получения переведенной строки в любой точке без контекста
                    +1

                    Пересмотрите подход. В двух словах — вам не должны быть нужны строки локализации вне активити/фрагмента. Текст отображается на UI, а не там, где нет контекста. Тот код, где вы решаете какой текст вывести (бизнес логика), не должен зависеть от каких-то там ресурсов

                      0
                      Спасибо. Я подумаю как переделать свой подход
                  0
                  ВСЕ ссылки на активити нужны при случаях создания ненужных копий активити(клики на уведомлениях, обработка Deep Link). И не надо говорить про singleInstance и прочие неработающие флаги(прибамбасы). Со всеми вытекающими последствиями c(над) LiveData. В правильной архитектуре LiveData — абсолютное зло.
                    0
                    С каких пор паттерн Наблюдатель зло? Может вы не умеете его готовить?
                    Работа с активити-тасками и стеком активитей хорошо описана в документации, и «прочие неработащие флаги» работают так как и должны. Хранить ссылки на все активити — вот где зло.
                    0
                    В случае создания ненужных копий активити выглядит смешно архитектура с одной активити, когда сама система не может ее поддерживать
                      0
                      Вы в своей «архитектуре» держите текущее состояние прямо в активити, поэтому вам не нравится, что система может ее пересоздавать по своему желанию?
                      0
                      При создании копии активити остаются в памяти(в стеке) она сама, а также НЕНУЖНЫЕ ВСЕ объекты, которые связаны с ней. Вы должны каким-то образом удалять ВЕСЬ этот ненужный хлам. Если вы используете потоковую(реактивную) архитектуру это просто ее разрушает. Решение этого — Moxy или пилите свою службу доставки данных(actions) до конкретного получателя(View, Presenter). Кому как нравиться. В меня наверно 20 лет вдалбливали, что данные ВСЕГДА ПАССИВНЫ. UI активен. В потоковых(реактивных) структурах выборка данных всегда полностью отвязана от UI. В UI(Presenter) передаются либо оповещения о прошедших событиях либо неизменяемые данные — по сути архитектура Flutter. Т.е. архитектура выглядит как View->Presenter->Запрос->Выборка данных(бизнес логика)->Результат->Служба доставки->Presenter->View. Служба доставки это Moxy или самописка. Данная архитектура с легкостью льется на Flutter — да на что угодно.
                        0
                        Уточнение — в потоковых системах (не реактивных и не во Flutter — это частные случаи реализаций) понятия состояние вообще нет. Это динамические системы. Всё течёт, всё меняется(Гераклит).
                          0
                          По поводу RecyclerView, не пробовали ли вы использовать ListAdapter, который использует DiffUtils под капотом? Было бы более интересно если бы вы описали старинный кейс, когда при notify, пересоздается view. А если в общем, всё что вы здесь представили напрочь нарушает разделение по слоям приложение. Если у вас безнес логика напрямую вызывает View, у меня для вас плохие новости
                            0
                            Не пробовали. Сразу использовался RecyclerView.Adapter без DiffUtils. Что касается пересоздания — как я понял из того, что нашел в интернете, RecyclerView сам решает, переиспользовать View или создать новую, а почему и как он решает — надо копаться под капотом. Единственно как удалось заметно повлиять на пересоздание — через обращение напрямую к View в ViewHolder, а не через notify. Но даже так, при самописном свайпе и смещении позиции View — периодически View создавалось, а не переиспользовалось. К слову, странность с пересозданием наблюдалась еще и у знакомых на другом проекте с другой архитектурой и стилем написания. Но все равно не исключаю возможности, что просто что-то сделано в адаптере не так.

                            Что касается «бизнес-логики» и View. Различные слои у нас точно есть, может не везде идеально и где-то глаз успел замылиться, но буду работать над этим. Учиться, учиться и еще раз учиться :)
                            0
                            изучите что такое di и clean architecture — поймете как можно удобно все решить. А еще включите leak canary и попробуйте написать java юнит тесты и поймете почему все эти предложения — тихий ужас.
                              0
                              Уже изучал. Уже частично используем. Так же периодически проверяем с помощью leak canary — проблем нет. Unit тест не используем и не планируем. Ни тихого ни громкого ужаса нет.
                              0
                              Правильно ли я понял, что это «пятничный» саркастический пост а-ля вредные советы? :)
                                0
                                Нет, это серьезный пост в четверг, от которого у многих пригорело
                                  +1
                                  Я думаю у многих пригорело потому что некоторые практики, которые вы описали, идут в разрез с представлениями о правильной архитектуре.
                                  Дам вам лишь конструктивный фидбэк, без негатива)

                                  Самое важное что нужно понять: делать что-то статическим полем для того, чтобы «можно было использовать откуда угодно» — плохая идея и code smell. Усиливается связь между вашими компонентами, что ухудшает тестирование и рефакторинг. Или захотите вы вашу утилиту переиспользовать в другом приложение, а там неявная завязка на ваш другой App-класс, которого нет в новом приложении.
                                  Любой класс/функция должны получать свои зависимости из вне, как аргумент функции или конструктора.
                                  Также, показ тоста из утилит — идея не очень. Всё, что касается отображения, должно происходить в активити/фрагмент. Смешение ответственностей очень плохо влияет на код, порождает много ошибок и нарушает SOLID.
                                    0
                                    Спасибо за конструктивный фидбэк, без негатива.
                                    Тестирование — тут мне нечего сказать, на проекте у нас его нет и я не приверженец этого подхода.
                                    Рефакторинг — сколько его ни было, никаких особых проблем не испытывал из-за своего подхода. Скорее проблемы случаются, когда ленишься прописывать сеттеры и обращаешься к переменным напрямую. Благо с kotlin их теперь можно переопределить в любой момент без труда
                                    Если передавать аргументы чуть сложнее базовых классов — их все равно придется переносить в новый проект. Если модуль удобен и универсален, пусть я может и потрачу чуть больше времени, но перенесу его полностью со всеми связями. Какие то базовые вещи, которые оправдали свою пользу — в любом случае окажутся и в новом проекте. Если модуль все же специфичен — и так и так придется его менять и адаптировать.
                                    Если поле существует и должно существовать в единственном экземпляре и оно может понадобиться по всему проекту, то почему сразу надо писать кучу кода, чтобы все о ней узнали? При сферическом коне в вакууме — это будет лучше статики. Ну а в реальности куча кода — либо вручную, либо через какие-то библиотеки.
                                    В идеальной работе приложения — toast и не выскочит. Но ради тех редких случаев, когда он понадобится, прокидывать во все методы контекст или какие либо колбеки, создавая связи, которые в теории могут обернуться утечкой памяти? А так — и утечек нет и сообщение отобразится не зависимо от того, что случилось с приложением.

                                    В любом случае это то, к чему я пришел исходя из рабочих проектов. То, что работает и что я с большой вероятностью возьму и в следующий раз. То, что получено путем проб и ошибок. Да, где-то это больше о работе и удобстве здесь и сейчас, чем о красоте и потенциальном удобстве когда-то в будущем.

                                    Я постараюсь учесть все, на что мне указали и сделать свой код еще лучше. И я рад, что не все закатывают глаза и сразу лепят минус. Еще раз спасибо за конструктив :)
                                0
                                А вот кстати, как по новым гайдам, используя Lifecycle, узнать, какая активити запущена, когда находишься в функции onMessageReceived (в сервисе FirebaseMessagingService)? У меня не получилось разобраться.

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

                                Самое читаемое