前一阵,看到一位掘友共享了一篇文章:Android简略的两级谈论功用完结,看得出来,是一位Android萌新记载的学习进程。我其时还留了一条主张:

主张用单RecyclerView+多ItemType+ListAdapter完结,保持UI层的清洁,把逻辑处理会集在数据源的转化上,比方打开/收起二级谈论能够运用flatMap和groupBy等操作符转化

其实我之前在工作中,也从前做过类似抖音的二级谈论的需求。但那个时候自己很菜,还没有用过Kotlin,协程更是没有触摸过,这个功用和另一位同事一起开发了两周才搞定。

刚好这个周末没啥事,就想着写一个简略完结抖音二级谈论根本功用的Demo。一方面,想试试自己现在开发这样一个需求会是什么样的体验;另一方面,也算是给Android掘友,尤其是萌新,共享一点事务开发的心得。

先上个效果图(没有UI,迁就看吧),写代码的整个进程花了4个小时左右,比较当初自己开发需求现已快了很多了哈。

给产品估个两天时间,摸一天半的鱼不过火吧(手动斜眼)

用一个RecyclerView实现抖音二级评论

需求拆分

这种大家常用的谈论功用其实也就没啥好拆分的了,简略列一下:

  • 默认展现一级谈论和二级谈论中的热评,能够上拉加载更多。
  • 二级谈论超越两条时,能够点击打开加载更多二级谈论,打开后能够点击收起折叠到初始状况。
  • 回复谈论后刺进到该谈论的下方。

技能选型

前面我在给掘友的谈论中,也提到了技能选型的关键:

单RecyclerView + 多ItemType + ListAdapter

这是根本的UI结构。

为啥要只用一个RecyclerView?最重要的原因,便是在RecyclerView中嵌套同方向RecyclerView,会有功能问题和滑动抵触。其次,当下声明式UI正是各方大佬推重的最佳开发实践之一,虽然咱们没有运用声明式UI基础上开发的Compose/Flutter技能,但其构建思想仍然对咱们的开发具有一定的指导意义。我猜测,androidx.recyclerview.widget.ListAdapter或许也是响应声明式UI召唤的一个针对RecyclerView的解决方案吧。

数据源的转化

数据驱动UI!

已然选用了ListAdapter,那么咱们就不应该再手动操作adapter的数据,再用各种notifyXxx方法来更新列表了。更提倡的做法是,基于data class浅复制,用Collection操作符对数据源的进行转化,然后将转化后的数据提交到adapter。为了进步数据转化功能,咱们能够基于协程进行异步处理。

graph LR
start[原List] --异步数据处理--> 新List --> stop[ListAdapter.submitList]
stop --> start

关键:

  • 浅复制

低成本生成一个全新的目标,以保证数据源的安全性。

data class Foo(val id: Int, val content: String)
val foo1 = Foo(0, "content")
val foo2 = foo1.copy(content = "updated content")
  • Collection操作符

Kotlin中供给了很多十分好用的Collection操作符,能灵敏运用的话,十分有利于咱们向声明式UI转型。

前面我提到了groupByflatMap这两个操作符。怎么运用呢?

以这个需求为例,咱们需求显现一级谈论、二级谈论和打开更多按钮,想要分别用一个data class来表示,但是后端回来的数据中又没有“打开更多”这样的数据,就能够这样处理:

// 从后端获取的数据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,
    )
}
  • 异步处理

前面咱们描述的数据源的转化进程,在Kotlin中,能够简略地被笼统为一个操作:

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

关于这个需求,数据源转化操作就包含了:分页加载,打开二级谈论,收起二级谈论,回复谈论等。按照常规,笼统一个接口出来。已然咱们要在协程结构下进行异步处理,需求给这个操作加一个suspend关键字。

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

为啥我给这个接口取名Reducer?假如你知道它的意思,说明你或许现已了解过MVI架构了;假如你还不知道它的意思,说明你能够去了解一下MVI了。哈哈!

不过今日不谈MVI,关于这样一个小Demo,彻底没必要上架构。但是,优秀架构为咱们供给的代码构建思路是有必要的!

这个Reducer,在这儿就算是咱们的小小事务架构了。

  • 异步2.0

前面谈到异步,咱们印象中或许主要是网络恳求、数据库/文件读写等IO操作。

这儿我想要延伸一下。

ActivitystartActivityForResult/onActivityResultDialog的拉起/回调,其实也能够看着是异步操作。异步与是否在主线程无关,而在于是否是实时回来成果。毕竟在主线程上跳转到其他页面,获取数据再回调回去运用,也是花了时间的啊。所以在协程的结构下,有一个更适合描述异步的词语:挂起(suspend)

说这有啥用呢?仍以这个需求为例,咱们点击“回复”后拉起一个对话框,输入谈论确认后回调给Activity,再进行网络恳求:

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()
        }
    }
    ...进行其他操作,如网络恳求
}

技能选型,或者说技能结构,咱们就完结了,乃至还谈到了部分细节了。接下来进行完好完结细节共享。

完结细节

MainActivity

基于上一章节的技能选型,咱们的MainActivity的完好代码便是这样了。

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(this@CommentAdapter.folding)
                        recyclerView.scrollToPosition(index)
                    }
                }
            }
        }
        recyclerView.adapter = commentAdapter
    }
}

RecyclerView设置一个CommentAdapter就行了,回调时也只需求把回调过来的Reducer调度到IO线程跑一下,得到新的数据listsubmitList就完事了。假如不是submitList后有列表的定位问题,代码还能更精简。假如有知道更好的解决办法的朋友,费事留言共享一下,感谢!

CommentAdapter

别以为我把逻辑处理扔到adapter中了哦!

AdapterViewHolder都是UI组件,咱们也需求尽量保持它们的清洁。

贴一下CommentAdapter

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
    }
}

能够看到,便是一个简略的多ItemTypeAdapter,仅有需求注意的便是,在Activity里传入的reduceBlock: Reducer.() -> Unit,也要传给每个ViewHolder

ViewHolder

篇幅原因,就只贴其中一个:

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))
        }
    }
}

也是很简略,仅有特别一点的处理,便是在onClickListener中,让reduceBlockinvoke一个Reducer完结。

Reducer

刚才在技能选型章节,现已提前展现了“回复谈论”这一操作的Reducer完结了,其他Reducer也差不多,比方打开谈论操作,也封装在一个Reducer完结ExpandReducer中,以下是完好代码:

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
            }
        }
    }
}

短短一段代码,咱们做了这些事:

  • 恳求网络数据Entity list(假数据)
  • 通过mapper转化成显现用的Item数据list
  • Item数据刺进到“打开更多”按钮前面
  • 最后,依据二级谈论加载是否完结,将“打开更多”的状况置为IDLELOADED_ALL

一个字:丝滑!

用于转化EntityItemmapper的代码也贴一下吧:

// 笼统
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")
            }
        }
    }
}

细心的朋友能够看到,在这儿我趁便也将热评也处理了:

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

makeHot()便是用buildSpannedString来完结的:

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

这儿能够提一句:尽量用CharSequence来笼统表示字符串,能够便利咱们灵敏地运用Span来减少UI代码。

data class

也贴一下相关的数据实体得了。

  • 网络数据(假数据)
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数据
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
        }
    }
}

这部分没啥好说的,能够注意两个点:

  • data class也是能够笼统的。但这边我处理不是很谨慎,比方CommentItem我把userIduserName也笼统出来了,其实不应该笼统出来。
  • 在基于Reducer的结构下,最好是把data class的属性都界说为val

结语

更多的代码就不贴了,贴太多影响观感。有兴趣的朋友能够移步源码。

总结一下完结心得:

  • 数据驱动UI
  • 对事务的精准笼统
  • 对异步的延伸理解
  • 灵敏运用Collection操作符
  • 没有UI和PM,写代码真TM爽!