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

В качестве примера я написал простое Android приложение, которое позволяет юзерам найти значение русского слова:

В данном примере GET запрос реализован через встроенные средства Java, которые находятся в пакете java.net.*

Парсинг JSON осуществляется через встроенный в Android пакет org.json.*

А для выполнения запроса в background потоке я использую функции обратного вызова и Java пакет java.util.concurrent.*

Также для поиска реализован Debounce эффект с задержкой в 500 мс.

Ну что ж пройдемся по всем частям более подробно

Делаем GET запрос без Retrofit'а)

Покажу сразу код:

open class GetRequest(
    private val url: String,
    private val executor: ExecutorService,
    private val handler: Handler
) {

    fun execute(onSuccess: (json: String) -> Unit, onError: (error: GetError) -> Unit) {
        executor.execute {

            var conn : HttpsURLConnection? = null

            try {
                val connection = URL(url).openConnection() as HttpsURLConnection
                conn = connection

                connection.requestMethod = "GET"
                connection.setRequestProperty("Content-Type", "application/json; utf-8")
                connection.connectTimeout = 5000
                connection.readTimeout = 5000

                val json = connection.inputStream.bufferedReader().readText()

                handler.post { onSuccess(json) }

            } catch (error: Exception) {
                conn?.disconnect()
                handler.post {
                    if (error is UnknownHostException) {
                        onError(GetError.MISSING_INTERNET)
                    } else {
                        onError(GetError.OTHER)
                    }
                }
            }
        }
    }

}

Мы прокидываем ExecucotService, чтобы выполнить наш запрос в background потоке.

Handler используется для возвращения результата на UI поток

HttpsURLConnection входит во встроенный пакет java.net.* и предназначен для выполнения сетевых запросов.

Параметры HttpsURLConnection я думаю вам понятны.

Затем мы читаем все данные через BufferedReader и отправляем результат дальше через функции обратного вызова, которые передаются в метод execute().

Обратите внимание, наш класс может иметь наследников.

Я сделал это лишь для небольшого удобства (можно смело юзать только GetRequest)

В моем тестовом приложении это DictGetRequest:

class DictGetRequest(word: String, executor: ExecutorService, handler: Handler)
    : GetRequest("https://api.dictionaryapi.dev/api/v2/entries/ru/$word", 
                 executor, handler)

Страшный парсинг JSON'а вручную ????

Пожалуй это выглядит очень страшно:

sealed interface DictResultData {

    fun toUi() : DictResultUi

    data class Success(private val word: String, private val definitions: List<DictDefinition>) : DictResultData {
        override fun toUi(): DictResultUi {
            return DictResultUi.Success(word, definitions)
        }
    }

    data class Error(@StringRes private val resId: Int) : DictResultData {
        override fun toUi(): DictResultUi {
            return DictResultUi.Error(resId)
        }
    }

    companion object {
        fun fromJson(json: String) : DictResultData {

            if (json.isJsonObject()) {
                return Error(R.string.nothing_found)
            }

            val jsonObject = json.toJsonArray().firstObject()

            val word = jsonObject.str("word")
            val jsonDefinitions = jsonObject.array("meanings")
                .firstObject()
                .array("definitions")

            val definitions = mutableListOf<DictDefinition>()

            for (i in 0 until jsonDefinitions.length()) {

                val jsonDefinition = jsonDefinitions.jsonObject(i)

                val definition = jsonDefinition.str("definition")
                val example = jsonDefinition.str("example")

                definitions.add(DictDefinition(definition, example))
            }

            return Success(word, definitions)
        }
    }

}

Я юзаю sealed interface потому что запрос может вернуть разные результаты ответа (ошибка или успех).

Логика работы метода fromJson() может показаться неочевидной.

Во-первых, здесь используются Kotlin расширения, которые я вынес отдельно:

fun String.isJsonObject() : Boolean {
    return JSONTokener(this).nextValue() is JSONObject
}

fun String.toJsonArray() : JSONArray {
    return JSONArray(this)
}

fun JSONObject.str(key: String, default: String = "") : String {
    return if (has(key))  getString(key) else default
}

fun JSONArray.firstObject() : JSONObject {
    return if (length() == 0) JSONObject() else getJSONObject(0)
}


fun JSONArray.jsonObject(index: Int) : JSONObject {
    return getJSONObject(index)
}

fun JSONObject.array(key: String, default: JSONArray = JSONArray()) : JSONArray {
    return if (has(key)) getJSONArray(key) else default
}

Во-вторых, fromJson() может вернуть либо ошибку либо успех и поэтому я проверяю, если JSON является объектом, то это ошибка (особенность ответа от сервера, в случае успеха это будет массив).

Репозиторий и наша ViewModel'ка ????

Давайте посмотрим на репозиторий и ViewModel'ку, они такие милые:

// Repository
class DictRepositoryImpl(private val executor: ExecutorService, private val handler: Handler) :
    DictRepository {
    override fun infoAboutWordBy(word: String, onSuccess: (dict: DictResultData) -> Unit) {
        val request = DictGetRequest(word, executor, handler)
        request.execute(
            { json -> onSuccess(DictResultData.fromJson(json)) },
            { error -> onSuccess(DictResultData.Error(error.resId)) }
        )
    }
}

// ViewModel
class DictViewModel(private val repo: DictRepository) : ViewModel() {

    private val wordUi = MutableLiveData<DictResultUi>()

    fun observe(lifecycleOwner: LifecycleOwner, observer: Observer<DictResultUi>) = wordUi.observe(lifecycleOwner, observer)

    fun searchWordDefinition(word: String) {
        if (word.isEmpty()) {
            return
        }

        wordUi.value = DictResultUi.Loading
        repo.infoAboutWordBy(word) { result ->
            wordUi.value = result.toUi()
        }
    }

}

Здесь все очевидно: в репозитории мы делаем GET запрос на сервер и через функции обратного вызова получаем результат, который далее передаем во ViewModel.

Репозиторий возвращает объект DictResultData класса, который мы маппим в DictResultUi:

sealed interface DictResultUi {
    object Loading: DictResultUi
    data class Error(@StringRes private val textResId: Int): DictResultUi {
        fun text(view: TextView) {
            view.setText(textResId)
        }
    }
    data class Success(private val word: String, private val definitions: List<DictDefinition>) : DictResultUi {

        fun word(view: TextView) {
            view.text = word
        }

        fun definitions(layout: LinearLayoutCompat) {
            layout.removeAllViews()
            definitions.mapIndexed { index, definition -> definition.str(index + 1) }
                .forEach { str ->
                    layout.addView(AppCompatTextView(layout.context).apply {
                        text = str
                        setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f)
                        setTextColor(ContextCompat.getColor(context, R.color.grey_300))
                        layoutParams = LinearLayoutCompat.LayoutParams(
                            LinearLayoutCompat.LayoutParams.MATCH_PARENT,
                            LinearLayoutCompat.LayoutParams.WRAP_CONTENT
                        ).apply {
                            bottomMargin = 8.dp(context)
                        }
                    })
                }
        }

    }
}

Не пугайтесь, я не стал сильно заморачиваться и полностью рефакторить код.

Ну и я просто обожаю создавать UI кодом ????

MainActivity и наш любимый Debounce эффект

Взглянем на MainActiivty:

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        val executor = Executors.newSingleThreadExecutor()
        val handler = Handler(Looper.getMainLooper())
        val viewModel = ViewModelProvider(this, DictViewModelFactory(DictRepositoryImpl(executor, handler)))
            .get(DictViewModel::class.java)

        viewModel.observe(this) { dictResult ->

            val isError = dictResult is DictResultUi.Error
            val isSuccess = dictResult is DictResultUi.Success
            val isLoading = dictResult is DictResultUi.Loading

            binding.frameLayout.isVisible = isLoading or isError
            binding.progress.isVisible = isLoading
            binding.errorText.isVisible = isError
            binding.definitionsLayout.isVisible = isSuccess
            binding.wordText.isVisible = isSuccess

            when (dictResult) {
                is DictResultUi.Error -> {
                    dictResult.text(binding.errorText)
                }
                is DictResultUi.Success -> {
                    dictResult.word(binding.wordText)
                    dictResult.definitions(binding.definitionsLayout)
                }
                else -> {}
            }

        }

        val debounce = Debounce(Handler(Looper.getMainLooper()))
        val runnable = Runnable { viewModel.searchWordDefinition(binding.searchEdit.text.toString()) }
        binding.searchEdit.onTextChange { debounce.run(runnable) }
        binding.searchBox.setEndIconOnClickListener { runnable.run() }
    }

}

Здесь мы создаем ViewModel, подписываемся на изменение LiveData и делаем запрос, когда набираем текст или нажимаем на кнопку поиска.

Класс Debounce выглядит следующим образом:

class Debounce(private val handler: Handler) {

    fun run(runnable: Runnable, delay: Long = 500) {
        handler.removeCallbacks(runnable)
        handler.postDelayed(runnable, delay)
    }

}

Здесь мы отменяем выполнение предыдущего запроса и запускаем новый, который выполнится через 500 мс, если мы не будем ничего писать в поле поиска.

Заключение

Я к сожалению не смог, да это и невозможно, разобрать все тонкости в одной статье.

Советую вам обратить внимание на следующие моменты:

  • параметры GET запроса, передача тела запроса, Headers и Cookies, ну и другие типы запросов, такие как POST, PUT, UPDATE и DELETE

  • принципы работы пула потоков и Handler.

  • конечно же рефакторинг, я написал не идеальный код и поэтому для его улучшения придется потратить определенное время (внедрение ExecutorService и других зависимостей через конструктор, разбиение на более мелкие части и создание интерфейсов)

Оставляю ссылку на рабочее приложение

Желаю всем, у кого не диабет теплых и сладких зимних вечеров (шутка) ????

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


  1. zagayevskiy
    08.01.2022 15:34
    -3

    ОМГ, что за дичь? Это шутка какая-то?


    1. kovserg
      08.01.2022 16:06
      -2

      А что вам не нравиться? Конечно еще не хватает сборки без gradle, что б разобраться.


      1. zagayevskiy
        08.01.2022 16:31
        -1

        Ну, например, то, что такой, с позволения сказать, код, писали джуны лет 6-7 назад(с поправкой на некоторые либы). Кому это полезно и зачем оно?


        1. KiberneticWorm Автор
          08.01.2022 17:12
          +4

          Во-первых, в современной разработке сейчас полно различных либ, и многие не понимают как они устроены внутри.

          Во-вторых в этом и состоит интерес написания кода, вы это должны чувствовать если уже не джун)

          Рекомендую вам посмотреть исходники телеграма, поизучайте, может быть вам станет интересно, какой это джун написал приложение, которое используют более 1 миллиарда юзеров и которое работает лучше любого WhatsApp.

          Ну и конечно покажите свой профессиональный код? Мне очень интересно посмотреть)

          Или у вас все таки диабет?


          1. zagayevskiy
            08.01.2022 19:46
            +3

            Я бы понял прикол, если бы тут был написан с нуля парсинг джсона, и хттп-соединение на низком уровне. А вот это вот – это не то, "как устроены внутри либы". Это просто говнокод. Используются такие же библиотеки, только доступные из коробки, и имеющие хреновый апи. Остальной код написан отвратно.

            Если прям сильно хочется увидеть мой код, посмотрите мою последнюю статью, там есть куски кода. Если будут комментарии, можно писать прямо там.

            Я общался несколько лет назад с разработчиком телеграма под андроид, его подход был довольно специфичен. Я сделал вывод, что он подходит не только лишь всем. Точнее, мало кто может это делать. Я бы иметь дело с этим кодом не хотел.

            Не понял выпад про диабет, видимо, как-то связано с сахаром (синтаксическим?).


            1. allswell
              08.01.2022 21:35
              -1

              интересно, что специфичного там? не понравилось отсутствие LayoutInflater, или полный контроль над всем происходящим?


              1. zagayevskiy
                08.01.2022 22:21

                На тот момент это было так, что "в любой непонятной ситуации" писалась кастомная штука под себя, в том числе с уходом в натив на С++. Не всякий разработчик(и вообще не всякая команда) осилит это поддерживать и развивать. Что там сейчас, я не знаю, код не смотрел.


                1. allswell
                  09.01.2022 00:03

                  Так цели такой и не стояло никогда, чтобы "всякий" разраб смог поддержать
                  даже чисто философски, не вдаваясь в код, логично: вы делаете что-то на нестандартном протоколе (а гонять байты, например, - уже джава не очень подходит), там очень много медиа, и желательно, чтобы оно все дружило и не тормозило (попробуйте lottie или gif штук 5 ХОТЯ БЫ одновременно проигрывать в scroll контейнере на среднем девайсе), да и вообще, если вы хотите сделать что-то реально быстрое, лучше конкурентов, то универсальные инструменты не подойдут, поэтому да, многие "велосипеды" переизобретаются или оптимизируются под конкретную задачу.
                  От себя скажу, что код поддерживаемый и, лично для меня, он гораздо лучше, чем современные модные проекты на 100 модулей и миллиард слоев, где до клиента полезная фича не долетит почти никогда, ведь ее реализация "не по канону".


                  1. zagayevskiy
                    09.01.2022 00:52
                    -1

                    Да окей, окей, я где-то говорил что-то негативное про него разве? Но там точно не вот этот вот говнокод из статьи.


                    1. allswell
                      09.01.2022 11:12
                      +1

                      в телеге такие же вещи используются

                      ну а вообще, в чем говнокод-то, хоть бы аргументировали, и подсказали как лучше, чем код автора унижать (реально диабет, что ли?)


                      1. CodeByZen
                        09.01.2022 21:43
                        +1

                        @zagayevskiyиз яндекса! же. Нельзя такого человека заставлять обосновывать свои слова. Это же ЯНДЕКСОИД!!!

                        P.S. как-же гаденько в этом яндексе, если человек, который пишет в корпоративный блог на хабре, может в комменты другого автора написать, что, - "Это просто говнокод", без объяснения. Сразу видно, @zagayevskiy большой профессионал, и сноб imho.


                      1. AlexWoodblock
                        10.01.2022 01:18
                        -1

                        Давайте я, не яндексоид, пройдусь по плохим частям кода в статье. Я искренне не понимаю, почему статья в плюсе.

                        Номер один:

                        private val executor = Executors.newSingleThreadExecutor()

                        Каждый раз, создавая экземпляр GetRequest, создается новый Executor с одним тредом. Тут либо непонимание автором, как работает Executor, либо кривой API этого самого GetRequest.

                        Номер два: с какой целью GetRequest помечен, как open? На каждый тип реквеста создавать подкласс? Для такой цели намного лучше подходит паттерн Factory.

                        Номер три: HttpsURLConnection потенциально утекает, т.к. его закрытием в случае ошибки никто не озаботился.

                        Номер четыре: почему в слое данных у нас находится UI-логика? abstract fun toUi() : DictResultUi абсолютно не место в слое данных, которые возвращаются в качестве ответа на запрос в сеть. Не говоря уже о том, что маппинг одного в другой абсолютно бесполезный - два одинаковых объекта с теми же самыми полями, но разными названиями. Ладно бы еще были разделены на те данные, что в слое данных и в слое представления, но нет - они смешаны за счет метода toUi()

                        Номер пять: абсолютно ужасное использование Reader'а.

                        В Котлине, к счастью, есть намного более лаконичный вариант, без while. Можно сильно упростить:

                        val content = bufferedReader.use {
                          it.readText()
                        }

                        И закроет reader корректно, и весь контент считает. Нет, конечно, можно сказать, что это все синтаксический сахар, но тогда можно и от много чего другого Котлин-специфического отказаться. Например, onTextChange

                        Номер шесть: sealed class можно бы на sealed interface заменить.

                        Номер семь: DictViewModel#found(word:) не очень удачное название. Это все таки что-то ближе к userRequestedSearch

                        Номер восемь:

                        if (dictResult is DictResultUi.Error) {
                          dictResult.text(binding.errorText)
                        }
                        
                        if (dictResult is DictResultUi.Success) {
                          dictResult.word(binding.wordText)
                          dictResult.definitions(binding.definitionsLayout)
                        }

                        Это спокойно можно было заменить оператором when

                        Номер девять: Если в течение 500 миллисекунд после вызовы Debounce#run(runnable:, delay:) обновится состояние, то ваш debounce не сработает.


                      1. KiberneticWorm Автор
                        10.01.2022 06:34
                        +1

                        Спасибо большое за конструктивный ответ.

                        1) поправил

                        2) я пометил как open лишь для создания более удобного подкласса с определенным базовым URL'ом, это лишь моя прихоть)

                        3) поправил

                        4) я не стал заморачиваться и писать domain слой, поэтому упростил, и маппинг не считается бесполезным, потому что в классе ui слоя я устанавливаю текст и заполняю LinearLayout текстовыми метками с определениями слова, а в классе data слоя я делаю парсинг json'а

                        5) поправил

                        6) поправил, спасибо, я не задумывался, что есть sealed interface'ы ))))

                        7) изменил название на searchWordDefinition - поиск определения слова

                        8) поправил

                        9) не совсем понял, можно пожалуйста подробнее


        1. k0ldbl00d
          08.01.2022 19:49
          +1

          Зато современные «сеньоры» пишут отличный код, но плохие приложения. В «Сбермаркете» так и не смогли реализовать debounce в поиске.


          1. zagayevskiy
            08.01.2022 20:00

            И это печально, согласен.


  1. AlexWoodblock
    09.01.2022 04:11

    Очень странная статья. Непонятно, на кого это рассчитано - тем, кто с разработкой под Android не на "вы", статья бесполезна. Новичкам эту статью можно рекомендовать только в качестве вредных советов (и нет, не из-за использования самописных библиотек). Если уж давать советы про то, что надо обратить внимание на

    принципы работы пула потоков и Handler.

    то хорошо бы еще было сказать, что создавать экземпляр Executor'а на каждый GET-реквест - это очень плохая затея. Можно тогда уж сразу создавать новый тред.

    Если уж мы отказываемся в этой статье от библиотек, давайте тогда и без ViewModel и LiveData сделаем, особенно учитывая то, что свои варианты этих двух классов как раз очень легко написать.

    Ну и стиль повествования очень уж кривой - какой-то хаос вперемешку с "how do you do, fellow kids".

    Увидев заплюсованную статью, ожидал увидеть разбор основ, а не типичный код, который можно увидеть, взяв на Upwork заказ "быстренько доделать приложение за 50 долларов".


  1. SerVB
    09.01.2022 10:01
    -1

    Новый runnable создается на каждый вызов debounce? Вероятно, сравнение будет давать ложь и удаление предыдущего не будет работать...


  1. petrovichtim
    10.01.2022 07:31

    Попробуйте тоже самое сделать на java и сравнить итоговые размеры апк файлов


  1. lim14
    11.01.2022 10:47

    На подскажете, какие языки поддерживает https://api.dictionaryapi.dev ?

    На их сайте не нашёл информации


  1. LuckyV
    11.01.2022 10:58

    Прикольно было бы еще отказаться от гугловской viewModel и сделать сохранение стейта между конфигурациями по похожему механизму.