Введение
В целом идея родилась с того что мы с друзьями присылали друг другу подарки в ВК (немного староверы) и прикрепляли к ним анекдоты. Одним из источников шуток для меня лично был сайт https://baneks.ru/. Но копировать с него анекдоты было до жути не удобно, плюс нет возможности нормально сохранять понравившиеся. С этого и выходит задача:
Реализовать получение анекдотов.
Реализовать сохранение понравившихся.
реализовать удобное копирование.
Используемые библиотеки
Для реализации использовались как стандартные библиотеки, так и пара достаточно специфических:
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.
Комментарии (3)
berez
08.07.2023 07:38Метод
fetchJokeByNumberAsync
выполняет получение анекдота по указанному номеру.-- Четырнадцать!
-- Ахахахаха!!!
-- Двадцать два!
-- Ну ты и пошляк...
Rusrst
Пора уже закопать setSupportActionBar, toolbar справляется со всем сам.
Что-то у вас с транное в адаптере, notify методы не нужны при diffutils
Livedata тоже пора уже отправить на свалку истории - flow получше будут.