Use a RecyclerView to implement secondary comments

Let’s take a look at the renderings first (there is no UI, so just take a look at it). The whole process of writing the code took about 4 hours, which was much faster than the original development needs.

It takes two days to estimate the product. One and a half days to fish is not too much (manual squinting)

demand splitting

In fact, there is nothing to separate this commonly used comment function. Let’s briefly list it:

  • Hot reviews from first-level reviews and second-level reviews are displayed by default, and you can pull up to load more.
  • When there are more than two secondary comments, you can click to expand to load more secondary comments. After expanding, you can click to collapse and return to the initial state.
  • Reply to a comment and insert it below the comment.

Technology selection

In my previous comments to fellow diggers, I also mentioned the key points of technology selection:

Single RecyclerView + Multiple ItemType + ListAdapter

This is the basic UI framework.

Why use just one RecyclerView? The most important reason is that RecyclerViewnesting in the same direction RecyclerViewwill cause performance problems and sliding conflicts. Secondly, the current declarative UICompose is one of the best development practices respected by all the big guys. Although we have not used the technology / technology developed based on the declarative UI Flutter, its construction ideas still have certain guiding significance for our development. I guess it may also be a targeted solution androidx.recyclerview.widget.ListAdapterin response to the call for declarative UI .RecyclerView

Data source conversion

Data driven UI !

Now that it is selected ListAdapter, we should no longer manually manipulate adapterthe data and use various notifyXxxmethods to update the list. The more recommended approach is to data classshallow copy based on

**, use Collectionoperators to convert the data source, and then submit the converted data to adapter. In order to improve data conversion performance, we can perform asynchronous processing based on coroutines.

Key points::

  • Shallow copy

Generate a brand new object at low cost to ensure the security of the data source.

data class Foo(val id: Int, val content: String)

val foo1 = Foo(0, "content")
val foo2 = foo1.copy(content = "updated content")
  • CollectionOperator

KotlinIt provides a large number of very useful Collectionoperators. If they can be used flexibly, it will be very helpful for us to transform to a declarative UI.

I mentioned groupBythese flatMaptwo operators earlier. How to use it?

Take this requirement as an example. We need to display first-level comments, second-level comments and expand more buttons. We want to use one data classto represent each, but there is no such data as "expand more" in the data returned by the backend. Deal with it this way:

// 从后端获取的数据List,包括有一级评论和二级评论,二级评论的parentId就等于一级评论的id
val loaded: List<CommentItem> = ... 
val grouped = loaded.groupBy { 
    // (1) 以一级评论的id为key,把源list分组为一个Map<Int, List<CommentItem>>
    (it as? CommentItem.Level1)?.id ?: (it as? CommentItem.Level2)?.parentId
    ?: throw IllegalArgumentException("invalid comment item")
}.flatMap { 
    // (2) 展开前面的map,展开时就可以在每级一级评论的二级评论后面添加一个控制“展开更多”的Item
    it.value + CommentItem.Folding(
        parentId = it.key,
    )
}
  • Asynchronous processing

The data source conversion process we described earlier can be simply abstracted into an operation in Kotlin:

List<CommentItem>.() -> List<CommentItem>

For this requirement, data source conversion operations include: loading in pages, expanding secondary comments, collapsing secondary comments, replying to comments, etc. As usual, abstract an interface. Since we want to perform asynchronous processing under the coroutine framework, we need to add a suspendkeyword to this operation.

interface Reducer {
    val reduce: suspend List<CommentItem>.() -> List<CommentItem>
}

Why did I name this interface Reducer? If you know what it means, it means you may have already understood MVIthe architecture; if you don't know what it means yet, it means you can learn about it MVI. Ha ha!

But let’s not talk about it today MVI. For such a small demo, there is no need to go into the architecture. However, the code construction ideas that excellent architecture provides us are necessary!

This Reduceris our small business structure here.

  • Async 2.0

When we talked about asynchronous before , our impression may mainly be IO operations such as network requests, database/file reading and writing, etc.

I want to expand a bit here.

ActivityThe startActivityForResult/ onActivityResultand Dialogthe pull-up/callback can actually be regarded as asynchronous operations. Asynchronous has nothing to do with whether it is on the main thread, but whether the results are returned in real time. After all, it takes time to jump to other pages on the main thread, obtain the data, and then call back to use it. Therefore, within the framework of coroutines, there is a word that is more suitable for describing asynchronous: suspend ( suspend) .

What's the use of saying this? Still taking this requirement as an example, we click "Reply" to bring up a dialog box, enter a comment to confirm, call back to it Activity, and then make a network request:

class ReplyDialog(context: Context, private val callback: (String) -> Unit) : Dialog(context) {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.dialog_reply)
        val editText = findViewById<EditText>(R.id.content)
        findViewById<Button>(R.id.submit).setOnClickListener {
            if (editText.text.toString().isBlank()) {
                Toast.makeText(context, "评论不能为空", Toast.LENGTH_SHORT).show()
                return@setOnClickListener
            }
            callback.invoke(editText.text.toString())
            dismiss()
        }
    }
}

suspend List<CommentItem>.() -> List<CommentItem> = {
    val content = withContext(Dispatchers.Main) {
        // 由于整个转换过程是在IO线程进行,Dialog相关操作需要转换到主线程操作
        suspendCoroutine { continuation ->
            ReplyDialog(context) {
                continuation.resume(it)
            }.show()
        }
    }
    ...进行其他操作,如网络请求
}

We have implemented technology selection, or technology framework, and even talked about some details. Next, we will share the complete implementation details.

Implementation details

MainActivity

Based on the technology selection in the previous chapter, our MainActivitycomplete code is like this.

class MainActivity : AppCompatActivity() {
    private lateinit var commentAdapter: CommentAdapter

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        val recyclerView = findViewById<RecyclerView>(R.id.recyclerView)
        commentAdapter = CommentAdapter {
            lifecycleScope.launchWhenResumed {
                val newList = withContext(Dispatchers.IO) {
                    reduce.invoke(commentAdapter.currentList)
                }
                val firstSubmit = commentAdapter.itemCount == 1
                commentAdapter.submitList(newList) {
                    // 这里是为了处理submitList后,列表滑动位置不对的问题
                    if (firstSubmit) {
                        recyclerView.scrollToPosition(0)
                    } else if (this@CommentAdapter is FoldReducer) {
                        val index = commentAdapter.currentList.indexOf([email protected])
                        recyclerView.scrollToPosition(index)
                    }
                }
            }
        }
        recyclerView.adapter = commentAdapter
    }
}

Just set RecyclerViewone CommentAdapter, and when calling back, you only need to schedule the callback Reducerto the IO thread to run, get new data, listand then submitListit's done. If it weren't submitListfor the positioning of the list, the code could be more streamlined. If anyone knows a better solution, please leave a message and share it, thank you!

CommentAdapter

Don’t think I’ve left logic processing adapterout of the question!

Adapterand ViewHolderare both UI components, and we also need to try to keep them clean.

Post CommentAdapterit

class CommentAdapter(private val reduceBlock: Reducer.() -> Unit) :
    ListAdapter<CommentItem, VH>(object : DiffUtil.ItemCallback<CommentItem>() {
        override fun areItemsTheSame(oldItem: CommentItem, newItem: CommentItem): Boolean {
            return oldItem.id == newItem.id
        }

        override fun areContentsTheSame(oldItem: CommentItem, newItem: CommentItem): Boolean {
            if (oldItem::class.java != newItem::class.java) return false
            return (oldItem as? CommentItem.Level1) == (newItem as? CommentItem.Level1)
                    || (oldItem as? CommentItem.Level2) == (newItem as? CommentItem.Level2)
                    || (oldItem as? CommentItem.Folding) == (newItem as? CommentItem.Folding)
                    || (oldItem as? CommentItem.Loading) == (newItem as? CommentItem.Loading)
        }
    }) {

    init {
        submitList(listOf(CommentItem.Loading(page = 0, CommentItem.Loading.State.IDLE)))
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
        val inflater = LayoutInflater.from(parent.context)
        return when (viewType) {
            TYPE_LEVEL1 -> Level1VH(
                inflater.inflate(R.layout.item_comment_level_1, parent, false),
                reduceBlock
            )

            TYPE_LEVEL2 -> Level2VH(
                inflater.inflate(R.layout.item_comment_level_2, parent, false),
                reduceBlock
            )

            TYPE_LOADING -> LoadingVH(
                inflater.inflate(
                    R.layout.item_comment_loading,
                    parent,
                    false
                ), reduceBlock
            )

            else -> FoldingVH(
                inflater.inflate(R.layout.item_comment_folding, parent, false),
                reduceBlock
            )
        }
    }

    override fun onBindViewHolder(holder: VH, position: Int) {
        holder.onBind(getItem(position))
    }

    override fun getItemViewType(position: Int): Int {
        return when (getItem(position)) {
            is CommentItem.Level1 -> TYPE_LEVEL1
            is CommentItem.Level2 -> TYPE_LEVEL2
            is CommentItem.Loading -> TYPE_LOADING
            else -> TYPE_FOLDING
        }
    }

    companion object {
        private const val TYPE_LEVEL1 = 0
        private const val TYPE_LEVEL2 = 1
        private const val TYPE_FOLDING = 2
        private const val TYPE_LOADING = 3
    }
}

ItemTypeAs you can see, it is a much simpler one Adapter. The only thing that needs to be noted is that Activitywhat is passed in reduceBlock: Reducer.() -> Unitmust also be passed to everyone ViewHolder.

ViewHolder

For space reasons, I will only post one of them:

abstract class VH(itemView: View, protected val reduceBlock: Reducer.() -> Unit) :
    ViewHolder(itemView) {
    abstract fun onBind(item: CommentItem)
}

class Level1VH(itemView: View, reduceBlock: Reducer.() -> Unit) : VH(itemView, reduceBlock) {
    private val avatar: TextView = itemView.findViewById(R.id.avatar)
    private val username: TextView = itemView.findViewById(R.id.username)
    private val content: TextView = itemView.findViewById(R.id.content)
    private val reply: TextView = itemView.findViewById(R.id.reply)
    override fun onBind(item: CommentItem) {
        avatar.text = item.userName.subSequence(0, 1)
        username.text = item.userName
        content.text = item.content
        reply.setOnClickListener {
            reduceBlock.invoke(ReplyReducer(item, itemView.context))
        }
    }
}

It is also very simple. The only special processing is onClickListenerto reduceBlockimplement invokeit in Reducer.

Reducer

ReducerJust now in the technology selection chapter, the implementation of the "reply to comments" operation has been demonstrated in advance . Others Reducerare similar, such as the expand comment operation, which is also encapsulated in an Reducerimplementation ExpandReducer. The following is the complete code:

data class ExpandReducer(
    val folding: CommentItem.Folding,
) : Reducer {
    private val mapper by lazy { Entity2ItemMapper() }
    override val reduce: suspend List<CommentItem>.() -> List<CommentItem> = {
        val foldingIndex = indexOf(folding)
        val loaded =
            FakeApi.getLevel2Comments(folding.parentId, folding.page, folding.pageSize).getOrNull()
                ?.map(mapper::invoke) ?: emptyList()
        toMutableList().apply {
            addAll(foldingIndex, loaded)
        }.map {
            if (it is CommentItem.Folding && it == folding) {
                val state =
                    if (it.page > 5) CommentItem.Folding.State.LOADED_ALL else CommentItem.Folding.State.IDLE
                it.copy(page = it.page + 1, state = state)
            } else {
                it
            }
        }
    }

}

In just one piece of code, we did these things:

  • Request network data Entity list(fake data)
  • By converting it into a data list mapperfor displayItem
  • Insert Itemdata in front of the "Expand More" button
  • Finally, depending on whether the loading of secondary comments is completed, set the status of "Expand More" to IDLEorLOADED_ALL

One word: Silky!

Please also post the code used to convert to Entity:Itemmapper

// 抽象
typealias Mapper<I, O> = (I) -> O
// 实现
class Entity2ItemMapper : Mapper<ICommentEntity, CommentItem> {
    override fun invoke(entity: ICommentEntity): CommentItem {
        return when (entity) {
            is CommentLevel1 -> {
                CommentItem.Level1(
                    id = entity.id,
                    content = entity.content,
                    userId = entity.userId,
                    userName = entity.userName,
                    level2Count = entity.level2Count,
                )
            }

            is CommentLevel2 -> {
                CommentItem.Level2(
                    id = entity.id,
                    content = if (entity.hot) entity.content.makeHot() else entity.content,
                    userId = entity.userId,
                    userName = entity.userName,
                    parentId = entity.parentId,
                )
            }

            else -> {
                throw IllegalArgumentException("not implemented entity: $entity")
            }
        }
    }
}

Attentive friends can see that I have also dealt with the hot comments here:

if (entity.hot) entity.content.makeHot() else entity.content

makeHot()It is buildSpannedStringused to achieve:

fun CharSequence.makeHot(): CharSequence {
    return buildSpannedString {
        color(Color.RED) {
            append("热评  ")
        }
        append(this@makeHot)
    }
}

One thing can be mentioned here: try CharSequenceto use it to abstractly represent strings, which can facilitate our flexible use Spanand reduce UI code.

data class

Also post the relevant data entities.

  • Network data (fake data)
interface ICommentEntity {
    val id: Int
    val content: CharSequence
    val userId: Int
    val userName: CharSequence
}

data class CommentLevel1(
    override val id: Int,
    override val content: CharSequence,
    override val userId: Int,
    override val userName: CharSequence,
    val level2Count: Int,
) : ICommentEntity
  • RecyclerView Itemdata
sealed interface CommentItem {
    val id: Int
    val content: CharSequence
    val userId: Int
    val userName: CharSequence

    data class Loading(
        val page: Int = 0,
        val state: State = State.LOADING
    ) : CommentItem {
        override val id: Int=0
        override val content: CharSequence
            get() = when(state) {
                State.LOADED_ALL -> "全部加载"
                else -> "加载中..."
            }
        override val userId: Int=0
        override val userName: CharSequence=""

        enum class State {
            IDLE, LOADING, LOADED_ALL
        }
    }

    data class Level1(
        override val id: Int,
        override val content: CharSequence,
        override val userId: Int,
        override val userName: CharSequence,
        val level2Count: Int,
    ) : CommentItem

    data class Level2(
        override val id: Int,
        override val content: CharSequence,
        override val userId: Int,
        override val userName: CharSequence,
        val parentId: Int,
    ) : CommentItem

    data class Folding(
        val parentId: Int,
        val page: Int = 1,
        val pageSize: Int = 3,
        val state: State = State.IDLE
    ) : CommentItem {
        override val id: Int
            get() = hashCode()
        override val content: CharSequence
            get() = when  {
                page <= 1 -> "展开20条回复"
                page >= 5 -> ""
                else -> "展开更多"
            }
        override val userId: Int = 0
        override val userName: CharSequence = ""

        enum class State {
            IDLE, LOADING, LOADED_ALL
        }
    }
}

There is not much to say in this part, but you can pay attention to two points:

  • data classIt can also be abstracted. But my handling here is not very rigorous. For example, CommentItemI abstracted userIdKazuya userName, but it should not be abstracted.
  • In Reducerthe framework based on , it is best data classto define the attributes as val.

To summarize the implementation experience:

  • Data driven UI
  • Precise abstraction of the business
  • Extended understanding of asynchronous
  • Flexible use Collectionof operators
  • There is no UI or PM, writing code is so fun!

Android study notes

Android performance optimization article: Android Framework underlying principles article: Android vehicle article: Android reverse security study notes: Android audio and video article: Jetpack family bucket article (including Compose): OkHttp source code analysis notes: Kotlin article: Gradle article: Flutter article: Eight knowledge bodies of Android: Android core notes: Android interview questions from previous years: The latest Android interview questions in 2023: Android vehicle development position interview exercises: Audio and video interview questions:https://qr18.cn/FVlo89
https://qr18.cn/AQpN4J
https://qr18.cn/F05ZCM
https://qr18.cn/CQ5TcL
https://qr18.cn/Ei3VPD
https://qr18.cn/A0gajp
https://qr18.cn/Cw0pBD
https://qr18.cn/CdjtAF
https://qr18.cn/DzrmMB
https://qr18.cn/DIvKma
https://qr18.cn/CyxarU
https://qr21.cn/CaZQLo
https://qr18.cn/CKV8OZ
https://qr18.cn/CgxrRy
https://qr18.cn/FTlyCJ
https://qr18.cn/AcV6Ap

Guess you like

Origin blog.csdn.net/weixin_61845324/article/details/132948695