Введение

В целом идея родилась с того что мы с друзьями присылали друг другу подарки в ВК (немного староверы) и прикрепляли к ним анекдоты. Одним из источников шуток для меня лично был сайт https://baneks.ru/. Но копировать с него анекдоты было до жути не удобно, плюс нет возможности нормально сохранять понравившиеся. С этого и выходит задача:

  1. Реализовать получение анекдотов.

  2. Реализовать сохранение понравившихся.

  3. реализовать удобное копирование.

Используемые библиотеки

Для реализации использовались как стандартные библиотеки, так и пара достаточно специфических:

Network

implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-gson:2.5.0'
implementation 'com.squareup.retrofit2:converter-scalars:2.9.0'
implementation 'com.google.code.gson:gson:2.8.9'
implementation 'org.jsoup:jsoup:1.13.1'

Т.к. нет доступа к API сайта, для получения анекдотов парсился HTML код полученный с помощью библиотеки jsoup. Для остального взаимодействия использовался retrofit.

Database

implementation "androidx.room:room-runtime:2.4.3"
kapt "androidx.room:room-compiler:2.4.3"

Room использовался для сохранения понравившихся анеков путём их записи в локальную БД на устройстве

DI

implementation "com.google.dagger:hilt-android:2.44"
kapt "com.google.dagger:hilt-compiler:2.44"

Для реализации DI был взят Hilt как наиболее простая и подходящая под задачи библиотека.

Design

implementation 'androidx.core:core-ktx:1.7.0'
implementation 'androidx.appcompat:appcompat:1.5.1'
implementation 'com.google.android.material:material:1.7.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.recyclerview:recyclerview:1.2.1'
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1'
implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
implementation 'androidx.fragment:fragment-ktx:1.5.4'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.1'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.4.0'

implementation('com.mikepenz.materialdrawer:library:0.9.5@aar') {
    transitive = true
}

Тут помимо Jetpack-библиотек присутствует также стороння библиотека Майка Пенза для отрисовки Material Drawer, которое использовалось как боковое меню для переключения между фрагментами.

Разбор кода

Разбор кода будем осуществлять не весь, лишь некоторые интересные моменты.

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        val (toolbar: Toolbar, fragmentContainer) = createToolbar()
        createDrawer(toolbar, fragmentContainer)
        initialize(savedInstanceState, fragmentContainer)
    }

    private fun initialize(savedInstanceState: Bundle?, fragmentContainer: Int) {
        val isFragmentContainerEmpty = (savedInstanceState == null)
        if (isFragmentContainerEmpty) {
            supportFragmentManager.commit {
                setReorderingAllowed(true)
                add(fragmentContainer, JokeListFragment.get())
                addToBackStack("joke_list")
            }
        }
    }

    private fun createToolbar(): Pair<Toolbar, Int> {
        val toolbar: Toolbar = findViewById<View>(R.id.toolbar) as Toolbar
        toolbar.setTitle(R.string.drawer_item_random_joke)
        setSupportActionBar(toolbar)
        supportActionBar!!.setDisplayHomeAsUpEnabled(true)
        val fragmentContainer = R.id.fragmentContainer
        return Pair(toolbar, fragmentContainer)
    }

    private fun createDrawer(toolbar: Toolbar, fragmentContainer: Int) {
        Drawer()
            .withActivity(this)
            .withToolbar(toolbar)
            .withActionBarDrawerToggle(true)
            .withHeader(R.layout.drawer_header)
            .addDrawerItems(
                PrimaryDrawerItem().withName(R.string.drawer_item_random_joke)
                    .withIcon(getDrawable(R.drawable.random)),
                PrimaryDrawerItem().withName(R.string.drawer_item_like)
                    .withIcon(getDrawable(R.drawable.heart)),
                PrimaryDrawerItem().withName(R.string.drawer_item_best_joke)
                    .withIcon(getDrawable(R.drawable.crown))
            )
            .withOnDrawerItemClickListener { _, _, position, _, _ ->
                when (position) {
                    1 -> {
                        supportFragmentManager.commit {
                            setReorderingAllowed(true)
                            replace(fragmentContainer, JokeListFragment.get())
                            addToBackStack("joke_list")
                            toolbar.setTitle(R.string.drawer_item_random_joke)
                        }
                    }
                    2 -> {
                        supportFragmentManager.commit {
                            setReorderingAllowed(true)
                            replace(fragmentContainer, LikeListFragment.newInstance())
                            addToBackStack("like_list")
                        }
                        toolbar.setTitle(R.string.drawer_item_like)
                    }
                    3 -> {
                        supportFragmentManager.commit {
                            setReorderingAllowed(true)
                            replace(fragmentContainer, BestListFragment.get())
                            addToBackStack("best_list")
                        }
                        toolbar.setTitle(R.string.drawer_item_best_joke)
                    }
                }
            }
            .build()
    }
}

Таким образом, код создает активность MainActivity с панелью инструментов (Toolbar) и боковой панелью (Navigation Drawer). При выборе элементов боковой панели происходит замена текущего фрагмента в контейнере fragmentContainer на соответствующий фрагмент, а также изменение заголовка панели инструментов. Это позволяет пользователю переключаться между различными фрагментами приложения, используя боковую панель и кнопку "домой" на панели инструментов для навигации назад.

private const val VIEW_TYPE_ITEM = 0
private const val VIEW_TYPE_LOAD = 1

class JokeAdapter(private val onItemClicked: (Joke, ToggleButton) -> Unit) :
    ListAdapter<Joke, ViewHolder>(JokeDiffCallback()) {

    private class LoadHolder(view: View) : ViewHolder(view)

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        return if (viewType == VIEW_TYPE_ITEM) {
            val view =
                LayoutInflater.from(parent.context)
                    .inflate(R.layout.joke_list_item, parent, false)
            JokeHolder(view){
                onItemClicked(currentList[it], view.findViewById(R.id.like_button))
            }
        } else {
            val view =
                LayoutInflater.from(parent.context)
                    .inflate(R.layout.progress_loading, parent, false)
            LoadHolder(view)
        }
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        if (holder is JokeHolder) {
            val joke = getItem(position)
            holder.bind(joke)
        }

    }

    fun addLoadingView() {
        submitList(ArrayList(currentList + null))
        notifyItemInserted(currentList.lastIndex)
    }

    fun deleteLoadingView() {
        if (currentList.size != 0) {
            submitList(ArrayList(currentList - currentList.last()))
            notifyItemRemoved(currentList.size)
        }
    }

    override fun getItemViewType(position: Int): Int {
        return if (getItem(position) == null) {
            VIEW_TYPE_LOAD
        } else {
            VIEW_TYPE_ITEM
        }
    }
}

Этот код представляет адаптер JokeAdapter, который используется для связывания данных списка анекдотов с RecyclerView в приложении.

В начале файла объявляются две константы: VIEW_TYPE_ITEM и VIEW_TYPE_LOAD. Они используются для определения типа представления, которое будет создано в адаптере. VIEW_TYPE_ITEM представляет элемент списка анекдотов, а VIEW_TYPE_LOAD представляет представление загрузки.

Класс JokeAdapter наследуется от ListAdapter, который является подклассом RecyclerView.Adapter и обеспечивает автоматическое обновление списка анекдотов при изменении данных.

Адаптер имеет внутренний класс LoadHolder, который наследуется от ViewHolder и представляет пустое представление для загрузки данных.

В конструктор класса передаётся лямба, т.к. нажатие на кнопку немного по разному обрабатывается в других классах.

class JokeHolder(view: View, onItemClicked: (Int) -> Unit) : RecyclerView.ViewHolder(view),
    View.OnLongClickListener {

    val context = view.context
    @Inject
    lateinit var repository: JokeRepository
    private var clipboard: ClipboardManager =
        context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
    private val jokeText: TextView = view.findViewById(R.id.joke_text)
    private val likeButton: ToggleButton = view.findViewById(R.id.like_button)
    private lateinit var joke: Joke

    init {
        jokeText.setOnLongClickListener(this)
        likeButton.setOnClickListener {
            onItemClicked(bindingAdapterPosition)
        }
    }

    fun bind(joke: Joke) {
        this.joke = joke
        jokeText.text = joke.text
        likeButton.isChecked = joke.isLiked
    }

    override fun onLongClick(p0: View?): Boolean {
        val clip = ClipData.newPlainText("Text of joke", jokeText.text)
        clipboard.setPrimaryClip(clip)
        Toast.makeText(context, "Text copied", Toast.LENGTH_SHORT).show()
        return true
    }
}

Класс JokeHolder представляет собой кастомный ViewHolder для элемента списка анекдотов в адаптере JokeAdapter. Он наследуется от RecyclerView.ViewHolder и реализует интерфейс View.OnLongClickListener для обработки долгого нажатия на элемент списка.

Метод onLongClick вызывается при долгом нажатии на jokeText. Он создает ClipData с текстом анекдота и копирует его в буфер обмена. Затем отображается всплывающее сообщение о копировании текста.

@Singleton
class JokeFetcher @Inject constructor(private val jokeApi: JokeApi) {
    suspend fun fetchJokeByNumberAsync(numberOfJoke: Int): Deferred<Joke> {
        return CoroutineScope(Dispatchers.IO).async {
            try {
                val textOfJoke =
                    jokeApi.getRandomJoke(numberOfJoke).body().toString().getJokeFromResponse()
                Joke(textOfJoke, numberOfJoke)
            } catch (e: Exception) {
                Joke("Default", 0)
            }
        }
    }

    private fun String.getJokeFromResponse(): String {
        return this
            .substringAfter("""name="description" content="""")
            .substringBefore("""">""")
    }

    fun getBestJokesListAsync(): Deferred<List<Int>> {
        return CoroutineScope(Dispatchers.IO).async {
            try {
                val response = Jsoup.connect("https://baneks.ru/top").get()
                val list = response.select("article").select("a")
                    .map { it.attr("href")}
                    .map { it.substringAfter("/").toInt()}
                list
            } catch (e: Exception) {
                Log.d(TAG, "getBestJokesListAsync error",e)
                MutableList(0) { 0 }
            }
        }
    }
}

Метод fetchJokeByNumberAsync выполняет получение анекдота по указанному номеру. Он использует корутины для выполнения запроса в отдельном потоке и возвращает отложенный результат (Deferred<Joke>). Внутри сопрограммы выполняется запрос к jokeApi для получения случайного анекдота с использованием jokeApi.getRandomJoke(numberOfJoke). Затем из ответа извлекается текст анекдота с помощью функции getJokeFromResponse(), и создается объект Joke с полученным текстом и номером анекдота. В случае ошибки, если запрос не удался, возвращается объект Joke с значениями по умолчанию.

Метод getBestJokesListAsync выполняет веб-скрапинг с использованием библиотеки Jsoup для получения списка ссылок на анекдоты с сайта. Затем происходит обработка списка ссылок, извлечение числовых идентификаторов анекдотов и возвращение списка этих идентификаторов в виде отложенного результата (Deferred<List<Int>>). В случае ошибки, если скрапинг не удался, возвращается пустой список.

Приватный метод getJokeFromResponse используется для извлечения текста анекдота из ответа API. Он выполняет обработку строки ответа с помощью строковых операций, чтобы получить текст анекдота.

private const val TAG = "BestListFragment"
private const val LOAD_THRESHOLD = 3

@AndroidEntryPoint
class BestListFragment : Fragment() {
    private val bestListViewModel: BestListViewModel by viewModels()
    private lateinit var recyclerView: RecyclerView
    @Inject
    lateinit var repository: JokeRepository
    private var isLoading = false

    private var adapter: JokeAdapter = JokeAdapter { joke, likeBtn ->
        val newJoke = Joke(joke.text, joke.number, isLiked = true)
        repository.likeJoke(newJoke)
        if (likeBtn.isChecked) {
            repository.likeJoke(joke)
            joke.isLiked = true
            adapterChange(joke)
        } else {
            repository.dislikeJoke(joke)
            joke.isLiked = false
            adapterChange(joke)
        }
    }

    fun adapterChange(joke: Joke) {
        adapter.notifyItemChanged(adapter.currentList.indexOf(joke))
    }

    private lateinit var layoutManager: LinearLayoutManager

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        val view = inflater.inflate(R.layout.fragment_best_list, container, false)
        createRecyclerView(view)
        return view
    }

    private fun createRecyclerView(view: View) {
        recyclerView = view.findViewById(R.id.best_joke_recycler_view)
        layoutManager = LinearLayoutManager(context)
        recyclerView.layoutManager = layoutManager
        recyclerView.adapter = adapter
        addScrollerListener()
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        createListeners()
    }

    private fun createListeners() {
        bestListViewModel.jokeLiveData.observe(
            viewLifecycleOwner
        ) { jokes ->
            adapter.submitList(ArrayList(jokes))
            Log.d(TAG, jokes.size.toString())
        }

        bestListViewModel.isLoadingLiveData.observe(
            viewLifecycleOwner
        ) { isLoading ->
            this.isLoading = isLoading
            if (isLoading) {
                adapter.addLoadingView()
            } else {
                adapter.deleteLoadingView()
            }
        }

        bestListViewModel.jokesFromDBLiveData.observe(
            viewLifecycleOwner
        ) { list ->
            if (list.isNotEmpty() && adapter.currentList.isNotEmpty()) {
                adapter.currentList.forEach {
                    if (it != null) it.isLiked = it in list
                }
            } else {
                adapter.currentList.forEach {
                    if (it != null) it.isLiked = false
                }
            }
            adapter.notifyDataSetChanged()
        }
    }

    private fun addScrollerListener() {
        recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
            override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
                super.onScrolled(recyclerView, dx, dy)
                if (!isLoading) {
                    if (layoutManager.findLastVisibleItemPosition() ==
                        adapter.currentList.size - LOAD_THRESHOLD
                    ) {
                        bestListViewModel.getBestJokeList()
                    }
                }
            }
        })
    }

    companion object {
        private var INSTANCE: BestListFragment? = null

        fun initialize() {
            if (INSTANCE == null) {
                INSTANCE = BestListFragment()
            }
        }

        fun get(): BestListFragment {
            return INSTANCE ?: throw IllegalStateException("JokeRepository must be initialized")
        }
    }
}

Класс BestListFragment представляет фрагмент, который отображает список лучших анекдотов.

Константа TAG определяет метку для использования в логировании. Константа LOAD_THRESHOLD определяет порог прокрутки, при достижении которого будет загружен следующий набор анекдотов.

Внутри BestListFragment объявляются необходимые свойства. bestListViewModel представляет экземпляр BestListViewModel, который будет использоваться для получения данных. repository представляет экземпляр JokeRepository, предоставляемый с помощью инъекции зависимостей. isLoading указывает, выполняется ли в данный момент загрузка данных.

adapter представляет экземпляр JokeAdapter, который будет использоваться для связывания данных анекдотов с RecyclerView. Он определяет функцию onItemClicked, которая вызывается при нажатии на кнопку "like" для анекдота. Внутри этой функции создается новый экземпляр анекдота с обновленным состоянием isLiked и вызываются соответствующие методы likeJoke или dislikeJoke репозитория. Затем происходит обновление адаптера путем вызова функции adapterChange.

createRecyclerView создает RecyclerView и настраивает его. Он использует LinearLayoutManager в качестве менеджера компоновки, устанавливает адаптер и добавляет слушатель прокрутки.

В методе onViewCreated устанавливаются слушатели для jokeLiveData, isLoadingLiveData и jokesFromDBLiveData из bestListViewModel. Когда данные изменяются, соответствующие обновления выполняются в адаптере.

addScrollerListener добавляет слушатель прокрутки к RecyclerView. При достижении порога прокрутки и если нет текущей загрузки данных, выполняется запрос на получение следующего набора анекдотов через bestListViewModel.

В компаньоне объявляются статические методы initialize и get для создания и получения экземпляра BestListFragment. Это сделано для обеспечения единственного экземпляра фрагмента.

private const val TAG = "BestListViewModel"

@HiltViewModel
class BestListViewModel @Inject constructor(
    private val repository: JokeRepository,
    private val jokeFetcher: JokeFetcher
) : ViewModel() {
    private val _jokeLiveData: MutableLiveData<MutableList<Joke>> = MutableLiveData()
    val jokeLiveData: LiveData<MutableList<Joke>> = _jokeLiveData

    private val _isLoadingLiveData: MutableLiveData<Boolean> = MutableLiveData()
    val isLoadingLiveData: LiveData<Boolean> = _isLoadingLiveData

    val jokesFromDBLiveData: LiveData<List<Joke>> = repository.getLikeJokes()

    var currentJokeList = mutableListOf<Joke>()

    private var alreadyLoaded = 0
    private var buffer = 7

    init {
        getBestJokeList()
    }

    fun getBestJokeList() {
        MainScope().launch {
            _isLoadingLiveData.value = true
            var listOfBestJokeNumbers = jokeFetcher.getBestJokesListAsync().await()
            listOfBestJokeNumbers = listOfBestJokeNumbers
                .filter { it !in listOfBestJokeNumbers.take(alreadyLoaded) }
            alreadyLoaded += buffer
            for (currentNumber in listOfBestJokeNumbers.take(buffer)) {
                val newJoke = jokeFetcher.fetchJokeByNumberAsync(currentNumber).await()
                currentJokeList.add(newJoke)
            }
            _isLoadingLiveData.value = false
            _jokeLiveData.value = currentJokeList
        }
    }
}

Свойство jokesFromDBLiveData представляет LiveData, которое получает список анекдотов из базы данных с помощью метода getLikeJokes() из repository.

Свойство currentJokeList представляет текущий список анекдотов.

В блоке init выполняется вызов метода getBestJokeList() для загрузки начального набора анекдотов.

Внутри корутины сначала устанавливается состояние загрузки через _isLoadingLiveData, затем выполняется запрос к jokeFetcher для получения списка лучших анекдотов через jokeFetcher.getBestJokesListAsync().await(). Затем фильтруется список, чтобы исключить уже загруженные анекдоты, и происходит цикл по оставшимся числам анекдотов. Для каждого числа анекдота выполняется запрос к jokeFetcher для получения анекдота с использованием jokeFetcher.fetchJokeByNumberAsync(currentNumber).await(), и полученный анекдот добавляется в currentJokeList. По завершении цикла устанавливается состояние загрузки и обновляется jokeLiveData с currentJokeList.

Заключение

Вот такой краткий (может и не совсем обзор приложения). С полной версией можно ознакомиться на GitHub.

https://github.com/K0RGA/Aneckdoter

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


  1. Rusrst
    08.07.2023 07:38

    Пора уже закопать setSupportActionBar, toolbar справляется со всем сам.

    Что-то у вас с транное в адаптере, notify методы не нужны при diffutils

    Livedata тоже пора уже отправить на свалку истории - flow получше будут.


  1. AndreyBN95
    08.07.2023 07:38

    Все достаточно неплохо расписано


  1. berez
    08.07.2023 07:38

    Метод fetchJokeByNumberAsync выполняет получение анекдота по указанному номеру.

    -- Четырнадцать!

    -- Ахахахаха!!!

    -- Двадцать два!

    -- Ну ты и пошляк...