网站在线生成器,包装网站开发,单页面的网站,做视频官方网站先上个效果图#xff08;没有UI#xff0c;将就看吧#xff09;#xff0c;写代码的整个过程花了4个小时左右#xff0c;相比当初自己开发需求已经快了很多了哈。 给产品估个两天时间#xff0c;摸一天半的鱼不过分吧#xff08;手动斜眼#xff09; 需求拆分
这种大家…先上个效果图没有UI将就看吧写代码的整个过程花了4个小时左右相比当初自己开发需求已经快了很多了哈。 给产品估个两天时间摸一天半的鱼不过分吧手动斜眼 需求拆分
这种大家常用的评论功能其实也就没啥好拆分的了简单列一下
默认展示一级评论和二级评论中的热评可以上拉加载更多。二级评论超过两条时可以点击展开加载更多二级评论展开后可以点击收起折叠到初始状态。回复评论后插入到该评论的下方。
技术选型
前面我在给掘友的评论中也提到了技术选型的要点
单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。为了提高数据转换性能我们可以基于协程进行异步处理。 要点
浅拷贝
低成本生成一个全新的对象以保证数据源的安全性。
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转型。
前面我提到了groupBy和flatMap这两个操作符。怎么使用呢
以这个需求为例我们需要显示一级评论、二级评论和展开更多按钮想要分别用一个data class来表示但是后端返回的数据中又没有“展开更多”这样的数据就可以这样处理
// 从后端获取的数据List包括有一级评论和二级评论二级评论的parentId就等于一级评论的id
val loaded: ListCommentItem ...
val grouped loaded.groupBy { // (1) 以一级评论的id为key把源list分组为一个MapInt, ListCommentItem(it as? CommentItem.Level1)?.id ?: (it as? CommentItem.Level2)?.parentId?: throw IllegalArgumentException(invalid comment item)
}.flatMap { // (2) 展开前面的map展开时就可以在每级一级评论的二级评论后面添加一个控制“展开更多”的Itemit.value CommentItem.Folding(parentId it.key,)
}异步处理
前面我们描述的数据源的转换过程在Kotlin中可以简单地被抽象为一个操作
ListCommentItem.() - ListCommentItem对于这个需求数据源转换操作就包括了分页加载展开二级评论收起二级评论回复评论等。按照惯例抽象一个接口出来。既然我们要在协程框架下进行异步处理需要给这个操作加一个suspend关键字。
interface Reducer {val reduce: suspend ListCommentItem.() - ListCommentItem
}为啥我给这个接口取名Reducer如果你知道它的意思说明你可能已经了解过MVI架构了如果你还不知道它的意思说明你可以去了解一下MVI了。哈哈
不过今天不谈MVI对于这样一个小Demo完全没必要上架构。但是优秀架构为我们提供的代码构建思路是有必要的
这个Reducer在这里就算是咱们的小小业务架构了。
异步2.0
前面谈到异步我们印象中可能主要是网络请求、数据库/文件读写等IO操作。
这里我想要延伸一下。
Activity的startActivityForResult/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 findViewByIdEditText(R.id.content)findViewByIdButton(R.id.submit).setOnClickListener {if (editText.text.toString().isBlank()) {Toast.makeText(context, 评论不能为空, Toast.LENGTH_SHORT).show()returnsetOnClickListener}callback.invoke(editText.text.toString())dismiss()}}
}suspend ListCommentItem.() - ListCommentItem {val content withContext(Dispatchers.Main) {// 由于整个转换过程是在IO线程进行Dialog相关操作需要转换到主线程操作suspendCoroutine { continuation -ReplyDialog(context) {continuation.resume(it)}.show()}}...进行其他操作如网络请求
}技术选型或者说技术框架咱们就实现了甚至还谈到了部分细节了。接下来进行完整实现细节分享。
实现细节
MainActivity
基于上一章节的技术选型咱们的MainActivity的完整代码就是这样了。
class MainActivity : AppCompatActivity() {private lateinit var commentAdapter: CommentAdapteroverride fun onCreate(savedInstanceState: Bundle?) {super.onCreate(savedInstanceState)setContentView(R.layout.activity_main)val recyclerView findViewByIdRecyclerView(R.id.recyclerView)commentAdapter CommentAdapter {lifecycleScope.launchWhenResumed {val newList withContext(Dispatchers.IO) {reduce.invoke(commentAdapter.currentList)}val firstSubmit commentAdapter.itemCount 1commentAdapter.submitList(newList) {// 这里是为了处理submitList后列表滑动位置不对的问题if (firstSubmit) {recyclerView.scrollToPosition(0)} else if (thisCommentAdapter is FoldReducer) {val index commentAdapter.currentList.indexOf(thisCommentAdapter.folding)recyclerView.scrollToPosition(index)}}}}recyclerView.adapter commentAdapter}
}给RecyclerView设置一个CommentAdapter就行了回调时也只需要把回调过来的Reducer调度到IO线程跑一下得到新的数据list再submitList就完事了。如果不是submitList后有列表的定位问题代码还能更精简。如果有知道更好的解决办法的朋友麻烦留言分享一下感谢
CommentAdapter
别以为我把逻辑处理扔到adapter中了哦
Adapter和ViewHolder都是UI组件我们也需要尽量保持它们的清洁。
贴一下CommentAdapter的
class CommentAdapter(private val reduceBlock: Reducer.() - Unit) :ListAdapterCommentItem, VH(object : DiffUtil.ItemCallbackCommentItem() {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 falsereturn (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_LEVEL1is CommentItem.Level2 - TYPE_LEVEL2is CommentItem.Loading - TYPE_LOADINGelse - TYPE_FOLDING}}companion object {private const val TYPE_LEVEL1 0private const val TYPE_LEVEL2 1private const val TYPE_FOLDING 2private const val TYPE_LOADING 3}
}可以看到就是一个简单的多ItemType的Adapter唯一需要注意的就是在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.userNamecontent.text item.contentreply.setOnClickListener {reduceBlock.invoke(ReplyReducer(item, itemView.context))}}
}也是很简单唯一特别一点的处理就是在onClickListener中让reduceBlock去invoke一个Reducer实现。
Reducer
刚才在技术选型章节已经提前展示了“回复评论”这一操作的Reducer实现了其他Reducer也差不多比如展开评论操作也封装在一个Reducer实现ExpandReducer中以下是完整代码
data class ExpandReducer(val folding: CommentItem.Folding,
) : Reducer {private val mapper by lazy { Entity2ItemMapper() }override val reduce: suspend ListCommentItem.() - ListCommentItem {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.IDLEit.copy(page it.page 1, state state)} else {it}}}}短短一段代码我们做了这些事
请求网络数据Entity list假数据通过mapper转换成显示用的Item数据list将Item数据插入到“展开更多”按钮前面最后根据二级评论加载是否完成将“展开更多”的状态置为IDLE或LOADED_ALL
一个字丝滑
用于转换Entity到Item的mapper的代码也贴一下吧
// 抽象
typealias MapperI, O (I) - O
// 实现
class Entity2ItemMapper : MapperICommentEntity, 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.contentmakeHot()就是用buildSpannedString来实现的
fun CharSequence.makeHot(): CharSequence {return buildSpannedString {color(Color.RED) {append(热评 )}append(thismakeHot)}
}这里可以提一句尽量用CharSequence来抽象表示字符串可以方便我们灵活地使用Span来减少UI代码。
data class
也贴一下相关的数据实体得了。
网络数据假数据
interface ICommentEntity {val id: Intval content: CharSequenceval userId: Intval userName: CharSequence
}data class CommentLevel1(override val id: Int,override val content: CharSequence,override val userId: Int,override val userName: CharSequence,val level2Count: Int,
) : ICommentEntityRecyclerView Item数据
sealed interface CommentItem {val id: Intval content: CharSequenceval userId: Intval userName: CharSequencedata class Loading(val page: Int 0,val state: State State.LOADING) : CommentItem {override val id: Int0override val content: CharSequenceget() when(state) {State.LOADED_ALL - 全部加载else - 加载中...}override val userId: Int0override val userName: CharSequenceenum 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,) : CommentItemdata class Level2(override val id: Int,override val content: CharSequence,override val userId: Int,override val userName: CharSequence,val parentId: Int,) : CommentItemdata class Folding(val parentId: Int,val page: Int 1,val pageSize: Int 3,val state: State State.IDLE) : CommentItem {override val id: Intget() hashCode()override val content: CharSequenceget() when {page 1 - 展开20条回复page 5 - else - 展开更多}override val userId: Int 0override val userName: CharSequence enum class State {IDLE, LOADING, LOADED_ALL}}
}这部分没啥好说的可以注意两个点
data class也是可以抽象的。但这边我处理不是很严谨比如CommentItem我把userId和userName也抽象出来了其实不应该抽象出来。在基于Reducer的框架下最好是把data class的属性都定义为val。
总结一下实现心得
数据驱动UI对业务的精准抽象对异步的延伸理解灵活使用Collection操作符没有UI和PM写代码真TM爽
Android 学习笔录
Android 性能优化篇https://qr18.cn/FVlo89 Android Framework底层原理篇https://qr18.cn/AQpN4J Android 车载篇https://qr18.cn/F05ZCM Android 逆向安全学习笔记https://qr18.cn/CQ5TcL Android 音视频篇https://qr18.cn/Ei3VPD Jetpack全家桶篇内含Composehttps://qr18.cn/A0gajp OkHttp 源码解析笔记https://qr18.cn/Cw0pBD Kotlin 篇https://qr18.cn/CdjtAF Gradle 篇https://qr18.cn/DzrmMB Flutter 篇https://qr18.cn/DIvKma Android 八大知识体https://qr18.cn/CyxarU Android 核心笔记https://qr21.cn/CaZQLo Android 往年面试题锦https://qr18.cn/CKV8OZ 2023年最新Android 面试题集https://qr18.cn/CgxrRy Android 车载开发岗位面试习题https://qr18.cn/FTlyCJ 音视频面试题锦https://qr18.cn/AcV6Ap