Pull to refresh

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

Reading time6 min
Views4K
Эта статья — набор небольших кубиков сахара для 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, то проверяем с той же позицией мы работаем что и раньше или нет
  • выполняем заложенную логику для касания

Возможно в будущем еще будут проблемы от этого способа, но на данный момент все работает и это хорошо.
Tags:
Hubs:
0
Comments34

Articles

Change theme settings