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 RecyclerView
nesting in the same direction RecyclerView
will 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.ListAdapter
in 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 adapter
the data and use various notifyXxx
methods to update the list. The more recommended approach is to data class
shallow copy based on
**, use Collection
operators 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")
Collection
Operator
Kotlin
It provides a large number of very useful Collection
operators. If they can be used flexibly, it will be very helpful for us to transform to a declarative UI.
I mentioned groupBy
these flatMap
two 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 class
to 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 suspend
keyword 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 MVI
the 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 Reducer
is 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.
Activity
The startActivityForResult
/ onActivityResult
and Dialog
the 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 MainActivity
complete 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 RecyclerView
one CommentAdapter
, and when calling back, you only need to schedule the callback Reducer
to the IO thread to run, get new data, list
and then submitList
it's done. If it weren't submitList
for 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 adapter
out of the question!
Adapter
and ViewHolder
are both UI components, and we also need to try to keep them clean.
Post CommentAdapter
it
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
}
}
ItemType
As you can see, it is a much simpler one Adapter
. The only thing that needs to be noted is that Activity
what is passed in reduceBlock: Reducer.() -> Unit
must 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 onClickListener
to reduceBlock
implement invoke
it in Reducer
.
Reducer
Reducer
Just now in the technology selection chapter, the implementation of the "reply to comments" operation has been demonstrated in advance . Others Reducer
are similar, such as the expand comment operation, which is also encapsulated in an Reducer
implementation 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
mapper
for displayItem
- Insert
Item
data 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
IDLE
orLOADED_ALL
One word: Silky!
Please also post the code used to convert to Entity
:Item
mapper
// 抽象
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 buildSpannedString
used to achieve:
fun CharSequence.makeHot(): CharSequence {
return buildSpannedString {
color(Color.RED) {
append("热评 ")
}
append(this@makeHot)
}
}
One thing can be mentioned here: try CharSequence
to use it to abstractly represent strings, which can facilitate our flexible use Span
and 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 Item
data
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 class
It can also be abstracted. But my handling here is not very rigorous. For example,CommentItem
I abstracteduserId
KazuyauserName
, but it should not be abstracted.- In
Reducer
the framework based on , it is bestdata class
to define the attributes asval
.
To summarize the implementation experience:
- Data driven UI
- Precise abstraction of the business
- Extended understanding of asynchronous
- Flexible use
Collection
of 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