解锁Android高阶技能,探秘实战Jetpack<十二>-------全面用LiveData+ViewModel+savedState重构之前实现的页面&架构商品详情模块1
实战:全面用LiveData+ViewModel+savedState重构之前实现的页面
在上一次https://www.cnblogs.com/webor2006/p/14006380.html已经对于JetPack的核心组件进行了全面细致的学习,并且也将它们应用到了咱们的主APP里面了,这里继续来巩固实操一把,将首页相关数据也进行一个全面改造,为啥要用JetPack的组件进行改造呢?这里一定要明白为啥要使用它们,下面简单的再来回忆一下:
也就是利用LiveData可以完全代替EventBus,并且它还比EventBus要强,不用咱们反注册,干嘛不用?
而SavedState它是ViewModel的升级,在内存不足APP被杀时也能保证数据能够复用,所以需要考虑APP被杀数据需要保存的场景用它进行数据恢复是非常合适的。
对于我自己的理解,用它们的原因最重要的是因为是Google力推的,而且都集成到了androidx标准库了,还有啥理由不去拥抱它们呢?
HomePageFragment:基于LiveData+ViewModel+SavedState改造数据请求
1、编写HomeViewModel,把首页Tab相关的所有接口请求挪进去:
目前先来看一下首页Tab数据的请求还是普通的方式:
接下来改成ViewModel,怎么改造呢,其实是一个套路,由于这是第一次改,所以从0开始慢慢来,之后就不会这么详细了,先来移动一下包:
1、然后新建一个HomeViewModel,将首页相关的ViewModel的逻辑都封装于此:
2、让它继承着ViewModel:
根据之前https://www.cnblogs.com/webor2006/p/13956989.htmlViewModel的使用方式来:
3、增加SavedState:
由于咱们想在内存不足app被杀时数据也能够快速得到恢复,所以单凭ViewModel是办不到的,它只能是在配置发生变更时有用,而此时需要使用ViewModel的进阶用法了,回忆一下之前https://www.cnblogs.com/webor2006/p/13993984.html所介绍的这块东东。
所以咱们定义一个对应的构造方法:
而在创建ViewModel的底层实现就会通过这个构造参数传递进去,回忆一下:
所以此时咱们就可以使用这个savedState对象进行数据的保存与恢复啦。
4、将请求挪到此类中:
package org.devio.`as`.proj.main.fragment.home import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import org.devio.`as`.proj.main.http.ApiFactory import org.devio.`as`.proj.main.http.api.HomeApi import org.devio.`as`.proj.main.model.TabCategory import org.devio.hi.library.restful.HiCallback import org.devio.hi.library.restful.HiResponse /** * 利用Jetpack中的LiveData+ViewModel+savedState组件进行数据的请求复用 */ class HomeViewModel(private val savedState: SavedStateHandle) : ViewModel() { fun queryCategoryTabs() { ApiFactory.create(HomeApi::class.java) .queryTabList().enqueue(object : HiCallback<List<TabCategory>> { override fun onSuccess(response: HiResponse<List<TabCategory>>) { val data = response.data if (response.successful() && data != null) { //todo } } override fun onFailed(throwable: Throwable) { } }) } }
2、使用LiveData发送数据:
而对于结果的监听咱们利用LiveData来进行改造,这个在之前的账户中心信息获取就已经使用过了:
具体如下:
package org.devio.`as`.proj.main.fragment.home import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import org.devio.`as`.proj.main.http.ApiFactory import org.devio.`as`.proj.main.http.api.HomeApi import org.devio.`as`.proj.main.model.TabCategory import org.devio.hi.library.restful.HiCallback import org.devio.hi.library.restful.HiResponse /** * 利用Jetpack中的LiveData+ViewModel+savedState组件进行数据的请求复用 */ class HomeViewModel(private val savedState: SavedStateHandle) : ViewModel() { fun queryCategoryTabs(): LiveData<List<TabCategory>?> { val liveData = MutableLiveData<List<TabCategory>?>() //先从savedState中进行获取,如果能获取则直接返回 val memCache = savedState.get<List<TabCategory>?>("categoryTabs") if (memCache != null) { liveData.postValue(memCache) return liveData } ApiFactory.create(HomeApi::class.java) .queryTabList().enqueue(object : HiCallback<List<TabCategory>> { override fun onSuccess(response: HiResponse<List<TabCategory>>) { val data = response.data if (response.successful() && data != null) { liveData.value = data savedState.set("categoryTabs", data) } } override fun onFailed(throwable: Throwable) { //ignore } }) return liveData } }
3、调用一下:
另外在updateUI时需要做一下小修改:
所以提取一下:
另外还有一个小细节,就是每次更新UI的这句判断就可以去掉了:
这也是使用Jetpack组件的好处,所以改一下:
至此整个类就已经改造成了,还是比较简单的,下面就依葫芦画瓢将剩下的界面快速改造一把。
HomeTabFragment:基于ViewModel+savedState实现首页Tab初始化数据的内存存储&复用:
将这个请求进行改造,这块就不过多说明了,基本都是套路,将请求的逻辑抽到ViewModel类中:
package org.devio.`as`.proj.main.fragment.home import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import org.devio.`as`.proj.main.http.ApiFactory import org.devio.`as`.proj.main.http.api.HomeApi import org.devio.`as`.proj.main.model.HomeModel import org.devio.`as`.proj.main.model.TabCategory import org.devio.hi.library.restful.HiCallback import org.devio.hi.library.restful.HiResponse import org.devio.hi.library.restful.annotation.CacheStrategy /** * 利用Jetpack中的LiveData+ViewModel+savedState组件进行数据的请求复用 */ class HomeViewModel(private val savedState: SavedStateHandle) : ViewModel() { fun queryCategoryTabs(): LiveData<List<TabCategory>?> { val liveData = MutableLiveData<List<TabCategory>?>() //先从savedState中进行获取,如果能获取则直接返回 val memCache = savedState.get<List<TabCategory>?>("categoryTabs") if (memCache != null) { liveData.postValue(memCache) return liveData } ApiFactory.create(HomeApi::class.java) .queryTabList().enqueue(object : HiCallback<List<TabCategory>> { override fun onSuccess(response: HiResponse<List<TabCategory>>) { val data = response.data if (response.successful() && data != null) { liveData.value = data savedState.set("categoryTabs", data) } } override fun onFailed(throwable: Throwable) { //ignore } }) return liveData } fun queryTabCategoryList( categoryId: String?, pageIndex: Int, cacheStrategy: Int ): LiveData<HomeModel?> { val liveData = MutableLiveData<HomeModel?>() val memCache = savedState.get<HomeModel>("categoryList") //只有是第一次加载时 才需要从内存中取 if (memCache != null && pageIndex == 1 && cacheStrategy == CacheStrategy.CACHE_FIRST) { liveData.postValue(memCache) return liveData } ApiFactory.create(HomeApi::class.java) .queryTabCategoryList(cacheStrategy, categoryId!!, pageIndex, 10) .enqueue(object : HiCallback<HomeModel> { override fun onSuccess(response: HiResponse<HomeModel>) { val data = response.data; if (response.successful() && data != null) { //一次缓存数据,一次接口数据 liveData.value = data //只有在刷新的时候,且不是本地缓存的数据 才存储到内容中 if (cacheStrategy != CacheStrategy.NET_ONLY && response.code == HiResponse.SUCCESS) { savedState.set("categoryList", data) } } else { liveData.postValue(null) } } override fun onFailed(throwable: Throwable) { liveData.postValue(null) } }) return liveData } }
然后调用改一下:
另外判断生命周期的代码也可以去掉了:
熟悉了套路之后改造起来也非常之快。
CategoryFragment:分类数据请求用LiveData+ViewModel重构
这个页面由于不需要考虑app被杀数据恢复的问题,所以此时就不需要用savedState了,下面快速改一把,这界面涉及到两个请求:
将其抽取到ViewModel中:
package org.devio.`as`.proj.main.fragment.category import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import org.devio.`as`.proj.main.http.ApiFactory import org.devio.`as`.proj.main.http.api.CategoryApi import org.devio.`as`.proj.main.model.Subcategory import org.devio.`as`.proj.main.model.TabCategory import org.devio.hi.library.restful.HiCallback import org.devio.hi.library.restful.HiResponse class CategoryViewModel : ViewModel() { fun querySubcategoryList(categoryId: String): LiveData<List<Subcategory>?> { val subcategoryListData = MutableLiveData<List<Subcategory>?>() ApiFactory.create(CategoryApi::class.java).querySubcategoryList(categoryId) .enqueue(simpleCallback(subcategoryListData)) return subcategoryListData } fun queryCategoryList(): LiveData<List<TabCategory>?> { val tabCategoryData = MutableLiveData<List<TabCategory>?>() ApiFactory.create(CategoryApi::class.java).queryCategoryList() .enqueue(simpleCallback<List<TabCategory>>(tabCategoryData)) return tabCategoryData } //回调抽取一下 private fun <T> simpleCallback(liveData: MutableLiveData<T?>): HiCallback<T> { return object : HiCallback<T> { override fun onSuccess(response: HiResponse<T>) { if (response.successful() && response.data != null) { liveData.postValue(response.data) } else { liveData.postValue(null) } } override fun onFailed(throwable: Throwable) { liveData.postValue(null) } } } }
调用一下:
package org.devio.`as`.proj.main.fragment.category import android.graphics.Color import android.os.Bundle import android.text.TextUtils import android.util.SparseIntArray import android.view.View import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView import androidx.lifecycle.Observer import androidx.lifecycle.ViewModelProvider import androidx.recyclerview.widget.GridLayoutManager import kotlinx.android.synthetic.main.fragment_category.* import org.devio.`as`.proj.common.ui.component.HiBaseFragment import org.devio.`as`.proj.common.ui.view.EmptyView import org.devio.`as`.proj.common.ui.view.loadUrl import org.devio.`as`.proj.main.R import org.devio.`as`.proj.main.model.Subcategory import org.devio.`as`.proj.main.model.TabCategory import org.devio.`as`.proj.main.route.HiRoute import org.devio.hi.ui.tab.bottom.HiTabBottomLayout /** * 商品分类 */ class CategoryFragment : HiBaseFragment() { private var viewModel: CategoryViewModel? = null private var emptyView: EmptyView? = null private val SPAN_COUNT = 3 private val layoutManager = GridLayoutManager(context, SPAN_COUNT) private val subcategoryList = mutableListOf<Subcategory>() private val groupSpanSizeOffset = SparseIntArray() private val decoration = CategoryItemDecoration({ position -> subcategoryList[position].groupName }, SPAN_COUNT) private val subcategoryListCache = mutableMapOf<String, List<Subcategory>>() override fun getLayoutId(): Int { return R.layout.fragment_category } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) HiTabBottomLayout.clipBottomPadding(root_container) content_loading.visibility = View.VISIBLE viewModel = ViewModelProvider(this).get(CategoryViewModel::class.java) queryCategoryList() } private fun queryCategoryList() { viewModel?.queryCategoryList()?.observe(viewLifecycleOwner, Observer { if (it == null) { showEmptyView() } else { onQueryCategoryListSuccess(it) } }) } private fun onQueryCategoryListSuccess(data: List<TabCategory>) { if (!isAlive) return emptyView?.visibility = View.GONE content_loading.visibility = View.GONE slider_view.visibility = View.VISIBLE slider_view.bindMenuView(itemCount = data.size, onBindView = { holder, position -> val category = data[position] // holder.menu_item_tilte 无法直接访问 // holder.itemView.menu_item_title. findviewbyid holder.findViewById<TextView>(R.id.menu_item_title)?.text = category.categoryName }, onItemClick = { holder, position -> val category = data[position] val categoryId = category.categoryId if (subcategoryListCache.containsKey(categoryId)) { onQuerySubcategoryListSuccess(subcategoryListCache[categoryId]!!) } else { querySubcategoryList(categoryId) } }) } private fun querySubcategoryList(categoryId: String) { viewModel?.querySubcategoryList(categoryId)?.observe(viewLifecycleOwner, Observer { if (it != null) { onQuerySubcategoryListSuccess(it) if (!subcategoryListCache.containsKey(categoryId)) { subcategoryListCache.put(categoryId, it) } } }) } private val spanSizeLookUp = object : GridLayoutManager.SpanSizeLookup() { override fun getSpanSize(position: Int): Int { var spanSize = 1 val groupName: String = subcategoryList[position].groupName val nextGroupName: String? = if (position + 1 < subcategoryList.size) subcategoryList[position + 1].groupName else null if (TextUtils.equals(groupName, nextGroupName)) { spanSize = 1 } else { //当前位置和 下一个位置 不再同一个分组 //1 .要拿到当前组 position (所在组)在 groupSpanSizeOffset 的索引下标 //2 .拿到 当前组前面一组 存储的 spansizeoffset 偏移量 //3 .给当前组最后一个item 分配 spansize count val indexOfKey = groupSpanSizeOffset.indexOfKey(position) val size = groupSpanSizeOffset.size() val lastGroupOffset = if (size <= 0) 0 else if (indexOfKey >= 0) { //说明当前组的偏移量记录,已经存在了 groupSpanSizeOffset ,这个情况发生在上下滑动, if (indexOfKey == 0) 0 else groupSpanSizeOffset.valueAt(indexOfKey - 1) } else { //说明当前组的偏移量记录,还没有存在于 groupSpanSizeOffset ,这个情况发生在 第一次布局的时候 //得到前面所有组的偏移量之和 groupSpanSizeOffset.valueAt(size - 1) } // 3 - (6 + 5 % 3 )第几列=0 ,1 ,2 spanSize = SPAN_COUNT - (position + lastGroupOffset) % SPAN_COUNT if (indexOfKey < 0) { //得到当前组 和前面所有组的spansize 偏移量之和 val groupOffset = lastGroupOffset + spanSize - 1 groupSpanSizeOffset.put(position, groupOffset) } } return spanSize } } private fun onQuerySubcategoryListSuccess(data: List<Subcategory>) { if (!isAlive) return decoration.clear() groupSpanSizeOffset.clear() subcategoryList.clear() subcategoryList.addAll(data) if (layoutManager.spanSizeLookup != spanSizeLookUp) { //设置一下sapnSizeLookup layoutManager.spanSizeLookup = spanSizeLookUp } slider_view.bindContentView( itemCount = data.size, itemDecoration = decoration, layoutManager = layoutManager, onBindView = { holder, position -> val subcategory = data[position] holder.findViewById<ImageView>(R.id.content_item_image) ?.loadUrl(subcategory.subcategoryIcon) holder.findViewById<TextView>(R.id.content_item_title)?.text = subcategory.subcategoryName }, onItemClick = { holder, position -> //是应该跳转到类目的商品列表页的 val subcategory = data[position] val bundle = Bundle() bundle.putString("categoryId", subcategory.categoryId) bundle.putString("subcategoryId", subcategory.subcategoryId) bundle.putString("categoryTitle", subcategory.subcategoryName) HiRoute.startActivity(context!!, bundle, HiRoute.Destination.GOODS_LIST) } ) } private fun showEmptyView() { if (!isAlive) return if (emptyView == null) { emptyView = EmptyView(context!!) emptyView?.setIcon(R.string.if_empty3) emptyView?.setDesc(getString(R.string.list_empty_desc)) emptyView?.setButton(getString(R.string.list_empty_action), View.OnClickListener { queryCategoryList() }) emptyView?.setBackgroundColor(Color.WHITE) emptyView?.layoutParams = LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.MATCH_PARENT ) root_container.addView(emptyView) } content_loading.visibility = View.GONE slider_view.visibility = View.GONE emptyView?.visibility = View.VISIBLE } }
另外对于更新UI的生命周期的判断可以去掉了,涉及到三处:
整个代码如下:
package org.devio.`as`.proj.main.fragment.category import android.graphics.Color import android.os.Bundle import android.text.TextUtils import android.util.SparseIntArray import android.view.View import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView import androidx.lifecycle.Observer import androidx.lifecycle.ViewModelProvider import androidx.recyclerview.widget.GridLayoutManager import kotlinx.android.synthetic.main.fragment_category.* import org.devio.`as`.proj.common.ui.component.HiBaseFragment import org.devio.`as`.proj.common.ui.view.EmptyView import org.devio.`as`.proj.common.ui.view.loadUrl import org.devio.`as`.proj.main.R import org.devio.`as`.proj.main.model.Subcategory import org.devio.`as`.proj.main.model.TabCategory import org.devio.`as`.proj.main.route.HiRoute import org.devio.hi.ui.tab.bottom.HiTabBottomLayout /** * 商品分类 */ class CategoryFragment : HiBaseFragment() { private var viewModel: CategoryViewModel? = null private var emptyView: EmptyView? = null private val SPAN_COUNT = 3 private val layoutManager = GridLayoutManager(context, SPAN_COUNT) private val subcategoryList = mutableListOf<Subcategory>() private val groupSpanSizeOffset = SparseIntArray() private val decoration = CategoryItemDecoration({ position -> subcategoryList[position].groupName }, SPAN_COUNT) private val subcategoryListCache = mutableMapOf<String, List<Subcategory>>() override fun getLayoutId(): Int { return R.layout.fragment_category } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) HiTabBottomLayout.clipBottomPadding(root_container) content_loading.visibility = View.VISIBLE viewModel = ViewModelProvider(this).get(CategoryViewModel::class.java) queryCategoryList() } private fun queryCategoryList() { viewModel?.queryCategoryList()?.observe(viewLifecycleOwner, Observer { if (it == null) { showEmptyView() } else { onQueryCategoryListSuccess(it) } }) } private fun onQueryCategoryListSuccess(data: List<TabCategory>) { emptyView?.visibility = View.GONE content_loading.visibility = View.GONE slider_view.visibility = View.VISIBLE slider_view.bindMenuView(itemCount = data.size, onBindView = { holder, position -> val category = data[position] // holder.menu_item_tilte 无法直接访问 // holder.itemView.menu_item_title. findviewbyid holder.findViewById<TextView>(R.id.menu_item_title)?.text = category.categoryName }, onItemClick = { holder, position -> val category = data[position] val categoryId = category.categoryId if (subcategoryListCache.containsKey(categoryId)) { onQuerySubcategoryListSuccess(subcategoryListCache[categoryId]!!) } else { querySubcategoryList(categoryId) } }) } private fun querySubcategoryList(categoryId: String) { viewModel?.querySubcategoryList(categoryId)?.observe(viewLifecycleOwner, Observer { if (it != null) { onQuerySubcategoryListSuccess(it) if (!subcategoryListCache.containsKey(categoryId)) { subcategoryListCache.put(categoryId, it) } } }) } private val spanSizeLookUp = object : GridLayoutManager.SpanSizeLookup() { override fun getSpanSize(position: Int): Int { var spanSize = 1 val groupName: String = subcategoryList[position].groupName val nextGroupName: String? = if (position + 1 < subcategoryList.size) subcategoryList[position + 1].groupName else null if (TextUtils.equals(groupName, nextGroupName)) { spanSize = 1 } else { //当前位置和 下一个位置 不再同一个分组 //1 .要拿到当前组 position (所在组)在 groupSpanSizeOffset 的索引下标 //2 .拿到 当前组前面一组 存储的 spansizeoffset 偏移量 //3 .给当前组最后一个item 分配 spansize count val indexOfKey = groupSpanSizeOffset.indexOfKey(position) val size = groupSpanSizeOffset.size() val lastGroupOffset = if (size <= 0) 0 else if (indexOfKey >= 0) { //说明当前组的偏移量记录,已经存在了 groupSpanSizeOffset ,这个情况发生在上下滑动, if (indexOfKey == 0) 0 else groupSpanSizeOffset.valueAt(indexOfKey - 1) } else { //说明当前组的偏移量记录,还没有存在于 groupSpanSizeOffset ,这个情况发生在 第一次布局的时候 //得到前面所有组的偏移量之和 groupSpanSizeOffset.valueAt(size - 1) } // 3 - (6 + 5 % 3 )第几列=0 ,1 ,2 spanSize = SPAN_COUNT - (position + lastGroupOffset) % SPAN_COUNT if (indexOfKey < 0) { //得到当前组 和前面所有组的spansize 偏移量之和 val groupOffset = lastGroupOffset + spanSize - 1 groupSpanSizeOffset.put(position, groupOffset) } } return spanSize } } private fun onQuerySubcategoryListSuccess(data: List<Subcategory>) { decoration.clear() groupSpanSizeOffset.clear() subcategoryList.clear() subcategoryList.addAll(data) if (layoutManager.spanSizeLookup != spanSizeLookUp) { //设置一下sapnSizeLookup layoutManager.spanSizeLookup = spanSizeLookUp } slider_view.bindContentView( itemCount = data.size, itemDecoration = decoration, layoutManager = layoutManager, onBindView = { holder, position -> val subcategory = data[position] holder.findViewById<ImageView>(R.id.content_item_image) ?.loadUrl(subcategory.subcategoryIcon) holder.findViewById<TextView>(R.id.content_item_title)?.text = subcategory.subcategoryName }, onItemClick = { holder, position -> //是应该跳转到类目的商品列表页的 val subcategory = data[position] val bundle = Bundle() bundle.putString("categoryId", subcategory.categoryId) bundle.putString("subcategoryId", subcategory.subcategoryId) bundle.putString("categoryTitle", subcategory.subcategoryName) HiRoute.startActivity(context!!, bundle, HiRoute.Destination.GOODS_LIST) } ) } private fun showEmptyView() { if (emptyView == null) { emptyView = EmptyView(context!!) emptyView?.setIcon(R.string.if_empty3) emptyView?.setDesc(getString(R.string.list_empty_desc)) emptyView?.setButton(getString(R.string.list_empty_action), View.OnClickListener { queryCategoryList() }) emptyView?.setBackgroundColor(Color.WHITE) emptyView?.layoutParams = LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.MATCH_PARENT ) root_container.addView(emptyView) } content_loading.visibility = View.GONE slider_view.visibility = View.GONE emptyView?.visibility = View.VISIBLE } }
ProfileFragment:基于LiveData+ViewModel改造数据请求
还有最后一个页面:
抽到ViewModel当中:
package org.devio.`as`.proj.main.fragment.profile import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import org.devio.`as`.proj.common.BuildConfig import org.devio.`as`.proj.main.http.ApiFactory import org.devio.`as`.proj.main.http.api.AccountApi import org.devio.`as`.proj.main.model.CourseNotice import org.devio.hi.library.restful.HiCallback import org.devio.hi.library.restful.HiResponse class ProfileViewModel : ViewModel() { fun queryCourseNotice(): LiveData<CourseNotice> { val noticeData = MutableLiveData<CourseNotice>(); ApiFactory.create(AccountApi::class.java).notice() .enqueue(object : HiCallback<CourseNotice> { override fun onSuccess(response: HiResponse<CourseNotice>) { if (response.data != null && response.data!!.total > 0) { noticeData.postValue(response.data) } } override fun onFailed(throwable: Throwable) { //ignore if (BuildConfig.DEBUG) { throwable.printStackTrace() } } }) return noticeData } }
调用一下:
基于ViewModel+LiveData架构商品详情模块:
目标:
接下来则来构建商品的详情模块,它是电商中至为重要的一个页面,该页面如果做得不好直接导致用户的流失公司收益的减少,所以要做好这么复杂的模块还是巨有一定的挑战的,下面先来看一下整体的大纲:
架构分析:
化整为零:
对于复杂模块得将其进行细分,一点点进行攻破, 先来看一下整个详情的效果:
大致可以看到有如下功能:
1、商品顶部Banner轮播:
2、标题滑动渐变效果:
3、商品评价:
4、店铺模块:
5、商品属性展示:
6、商品图片广告长图展示,这个是由多张图展示的,就不多说了,人人皆知的效果。
7、相似商品推荐列表:
8、底部操作区域:
针对这么多列表类型拆分成多个独立的HiDataItem,需求拆分按照数据流入的规则,看一下Item拆解如下:
而这里涉及到的用户交互:
疑难解惑:
- 评论标签流式布局-ChipGroup,注意滑动复用问题
通常的写法是会用RecyclerView+FlowLayoutManager来实现,但是咱们这种个数不多,也不存在滑动复用的问题,所有用它来实现有点小题大作了,所以这里会以一种全新的思路进行开发。 - 商品相册浏览避免滑动抖动,需要提前预设宽高,图片载加成功后再等比计算实际尺寸。
- 滑动标题栏渐变,需要动态计算白色---透明色的中间颜色值。
- 页面布局样式:GridLayoutManager(spanCount=2)。
其实就是指这两个Grid:
对于这块会再嵌一个RecyclerView,然后将它的spansize设置为3列,而
而对于它则对整个RecyclerView的GridLayoutManager设置为2列既可。
大厂经验分享【涨姿势】:
这里来看一下像大厂对于这种重量级的详情页可能会用到哪些手段呢,这里纵观一下:
骨架屏:
使用页面加载更加真实,减少等待感。也就是请设计切一张跟详情类似的图片用来先前展示。
predraw:
也就是在接口请求之前使用列表页的数据进行预渲染头部信息,这样在页面一打开时就能看到商品轮播,名称,价格等基础信息, 而当数据成功请求之后再做页面刷新既可,如果使用这种方式那就没必要使用骨架屏了。
Http接口耗时优化:
接口合并。多个Http请求合并到一个总的接口。由服务端做商品详情信息的组装,减少http时延。对于大厂像详情页基本上都是一个接口,不会弄非常多的接口的。
图片加载滑动优化:
由于图片在加载之前是无法知道它的宽高值的,那么在列表滑动时就会出现列表闪动现象,为了解决此类问题,通常会有如下几种解决方案:
- 产品约定好图片的宽高比(1:1,3:4,9:16),从而根据宽度计算出高度进行展示。
- 根据Url携带的图片实际尺寸等比计算出视图的宽高,这里需要借助于CDN的能力(比如:https://img.xxx.com/tfs/android-logo-large-720-720.png)。
- cdn裁剪,根据ImageView的宽高size,往图片Url上拼接尺寸信息(100-100),根据当前设备评分,网络环境拼接quanlity=70图片质量参数。
- 如果不具备以上条件怎么办?视图添加到列表上之前,设置宽高相等的尺寸,图片下载完成后,等比缩放视图的尺寸。可以有效防止图片加载成功页面闪跳的问题。【咱们要采用的方案】
搭建详情页整体结构:
1、定义详情API:
其数据格式比较复杂:
下面来定义一下:
package org.devio.`as`.proj.main.http.api import org.devio.`as`.proj.main.model.DetailModel import org.devio.hi.library.restful.HiCall import org.devio.hi.library.restful.annotation.GET import org.devio.hi.library.restful.annotation.Path interface DetailApi { @GET("goods/detail/{id}") fun queryDetail(@Path("id") goodsId: String): HiCall<DetailModel> }
其中需要定义一下DetailModel数据模型:
package org.devio.`as`.proj.main.model data class DetailModel( val categoryId: String, val commentCountTitle: String, val commentModels: List<CommentModel>?, val commentTags: String, val completedNumText: String, val createTime: String, val flowGoods: List<GoodsModel>?, val gallery: List<SliderImage>?, val goodAttr: List<MutableMap<String, String>>?, val goodDescription: String, val goodsId: String, val goodsName: String, val isFavorite: Boolean, val groupPrice: String, val hot: Boolean, val marketPrice: String, val shop: Shop, val similarGoods: List<GoodsModel>?, val sliderImage: String, val sliderImages: List<SliderImage>?, val tags: String ) data class CommentModel( val avatar: String, val content: String, val nickName: String ) data class Shop( val completedNum: String, val evaluation: String, val goodsNum: String, val logo: String, val name: String ) data class Favorite(val goodsId: String, var isFavorite: Boolean)
2、新建Activity:
package org.devio.`as`.proj.main.biz.detail import android.os.Bundle import com.alibaba.android.arouter.facade.annotation.Route import org.devio.`as`.proj.common.ui.component.HiBaseActivity import org.devio.`as`.proj.main.R /** * 商品详情页 */ @Route(path = "/detail/main") class DetailActivity : HiBaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_detail) } }
3、准备布局:
这里采用约束布局,重要的页面能尽量减少布局的嵌套尽量减少, 关于布局这块只对关键处进行说明,因为这块基本上都比较熟了,
<?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:id="@+id/root_container" android:layout_width="match_parent" android:layout_height="match_parent" android:background="@color/color_eee"> <androidx.recyclerview.widget.RecyclerView android:id="@+id/recycler_view" android:layout_width="match_parent" android:layout_height="0dp" android:overScrollMode="never" app:layout_constraintBottom_toTopOf="@+id/bottom_layout" app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintTop_toTopOf="parent" /> <!-- 底部操作栏 --> <LinearLayout android:id="@+id/bottom_layout" android:layout_width="match_parent" android:layout_height="58dp" android:background="@color/color_white" android:orientation="horizontal" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintLeft_toLeftOf="parent"> <org.devio.as.proj.common.ui.view.IconFontTextView android:id="@+id/action_favorite" android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:gravity="center" android:text="\n收藏" android:textColor="@color/color_999" android:textSize="@dimen/sp_14" /> <TextView android:id="@+id/action_order" android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:background="@color/color_de3" android:gravity="center" android:textColor="@color/color_white" android:textSize="@dimen/sp_14" tools:text="¥29元\n现在购买" /> </LinearLayout> </androidx.constraintlayout.widget.ConstraintLayout>
其中这块标红处需要说明一下,它需要设置成0dp:
注意:它不能是match_parent,因为它会充满整个高度:
接下来准备标题:
<?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:id="@+id/root_container" android:layout_width="match_parent" android:layout_height="match_parent" android:background="@color/color_eee"> <!-- 标题栏 --> <FrameLayout android:id="@+id/title_bar" android:layout_width="match_parent" android:layout_height="70dp" android:fitsSystemWindows="true" app:layout_constraintRight_toLeftOf="parent" app:layout_constraintTop_toTopOf="parent"> <org.devio.as.proj.common.ui.view.IconFontTextView android:id="@+id/action_back" android:layout_width="wrap_content" android:layout_height="wrap_content" android:gravity="center" android:paddingLeft="12dp" android:paddingTop="@dimen/dp_5" android:paddingRight="12dp" android:paddingBottom="@dimen/dp_5" android:text="@string/if_back" android:textSize="18sp" /> <org.devio.as.proj.common.ui.view.IconFontTextView android:id="@+id/action_share" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="right" android:gravity="center" android:paddingLeft="12dp" android:paddingTop="@dimen/dp_5" android:paddingRight="12dp" android:paddingBottom="@dimen/dp_5" android:text="@string/if_share" android:textSize="18sp" /> </FrameLayout> <androidx.recyclerview.widget.RecyclerView android:id="@+id/recycler_view" android:layout_width="match_parent" android:layout_height="match_parent" android:overScrollMode="never" app:layout_constraintBottom_toTopOf="@+id/bottom_layout" app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintTop_toTopOf="parent" /> <!-- 底部操作栏 --> <LinearLayout android:id="@+id/bottom_layout" android:layout_width="match_parent" android:layout_height="58dp" android:background="@color/color_white" android:orientation="horizontal" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintLeft_toLeftOf="parent"> <org.devio.as.proj.common.ui.view.IconFontTextView android:id="@+id/action_favorite" android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:gravity="center" android:text="\n收藏" android:textColor="@color/color_999" android:textSize="@dimen/sp_14" /> <TextView android:id="@+id/action_order" android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:background="@color/color_de3" android:gravity="center" android:textColor="@color/color_white" android:textSize="@dimen/sp_14" tools:text="¥29元\n现在购买" /> </LinearLayout> </androidx.constraintlayout.widget.ConstraintLayout>
其中由于需要沉浸式的效果,这里给标题View增加了一个这个属性:
4、设置状态栏风格:
5、注入Arouter:
package org.devio.`as`.proj.main.biz.detail import android.graphics.Color import android.os.Bundle import android.text.TextUtils import com.alibaba.android.arouter.facade.annotation.Autowired import com.alibaba.android.arouter.facade.annotation.Route import org.devio.`as`.proj.common.ui.component.HiBaseActivity import org.devio.`as`.proj.main.R import org.devio.`as`.proj.main.model.GoodsModel import org.devio.`as`.proj.main.route.HiRoute import org.devio.hi.library.util.HiStatusBar /** * 商品详情页 */ @Route(path = "/detail/main") class DetailActivity : HiBaseActivity() { @JvmField @Autowired var goodsId: String? = null /*此字段是用来提前加载商品轮播及基础信息用的*/ @JvmField @Autowired var goodsModel: GoodsModel? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) HiStatusBar.setStatusBar(this, true, statusBarColor = Color.TRANSPARENT, translucent = true) HiRoute.inject(this) assert(!TextUtils.isEmpty(goodsId)) { " goodsId must bot be null" } setContentView(R.layout.activity_detail) } }
6、initView():
7、数据请求API准备:
此时则需要使用ViewModel了,新建一个类:
package org.devio.`as`.proj.main.biz.detail import androidx.lifecycle.ViewModel class DetailViewModel() : ViewModel() { }
对于ViewModel的使用目前也已经比较熟悉了,不过这里用一种自定义参数的方式演练一把,对于ViewModel带参数不是正常只支持这两种嘛:
用的都是系统的参数,但是!!!有些情况可以需要携带一些自定义的参数,就比如此时此刻,对于详情的请求需要有一个goodsId,所以看一下这时该怎么来定义ViewModel:
此时需要指定一下创建工厂,因为我们在获得ViewModel时可以指定一个factory:
而工厂的编写方法可以参数系统创建的思路:
所以咱们可以这样定义:
package org.devio.`as`.proj.main.biz.detail import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelStoreOwner class DetailViewModel(val goodsId: String?) : ViewModel() { companion object { private class DetailViewModelFactory(val goodsId: String?) : ViewModelProvider.NewInstanceFactory() { override fun <T : ViewModel?> create(modelClass: Class<T>): T { try { val constructor = modelClass.getConstructor(String::class.java) if (constructor != null) { return constructor.newInstance(goodsId) } } catch (exception: Exception) { //ignore } //如果发生异常,则直接用降级方案由父类进行创建 return super.create(modelClass) } } fun get(goodsId: String?, viewModelStoreOwner: ViewModelStoreOwner): DetailViewModel { return ViewModelProvider(viewModelStoreOwner, DetailViewModelFactory(goodsId)).get( DetailViewModel::class.java ) } } }
其中标红的可以看一下父类的创建行为:
接下来定义请求方法:
package org.devio.`as`.proj.main.biz.detail import android.text.TextUtils import androidx.lifecycle.* import com.alibaba.android.arouter.BuildConfig import org.devio.`as`.proj.main.http.ApiFactory import org.devio.`as`.proj.main.http.api.DetailApi import org.devio.`as`.proj.main.model.DetailModel import org.devio.hi.library.restful.HiCallback import org.devio.hi.library.restful.HiResponse class DetailViewModel(val goodsId: String?) : ViewModel() { companion object { private class DetailViewModelFactory(val goodsId: String?) : ViewModelProvider.NewInstanceFactory() { override fun <T : ViewModel?> create(modelClass: Class<T>): T { try { val constructor = modelClass.getConstructor(String::class.java) if (constructor != null) { return constructor.newInstance(goodsId) } } catch (exception: Exception) { //ignore } //如果发生异常,则直接用降级方案由父类进行创建 return super.create(modelClass) } } fun get(goodsId: String?, viewModelStoreOwner: ViewModelStoreOwner): DetailViewModel { return ViewModelProvider(viewModelStoreOwner, DetailViewModelFactory(goodsId)).get( DetailViewModel::class.java ) } } fun queryDetailData(): LiveData<DetailModel?> { val pageData = MutableLiveData<DetailModel?>() if (!TextUtils.isEmpty(goodsId)) { ApiFactory.create(DetailApi::class.java).queryDetail(goodsId!!) .enqueue(object : HiCallback<DetailModel> { override fun onSuccess(response: HiResponse<DetailModel>) { if (response.successful() && response.data != null) { pageData.postValue(response.data) } else { pageData.postValue(null) } } override fun onFailed(throwable: Throwable) { pageData.postValue(null) if (BuildConfig.DEBUG) { throwable.printStackTrace() } } }) } return pageData } }
8、发起请求:
package org.devio.`as`.proj.main.biz.detail import android.graphics.Color import android.os.Bundle import android.text.TextUtils import android.view.View import androidx.constraintlayout.widget.ConstraintLayout import androidx.lifecycle.Observer import androidx.recyclerview.widget.GridLayoutManager import com.alibaba.android.arouter.facade.annotation.Autowired import com.alibaba.android.arouter.facade.annotation.Route import kotlinx.android.synthetic.main.activity_detail.* import org.devio.`as`.proj.common.ui.component.HiBaseActivity import org.devio.`as`.proj.common.ui.view.EmptyView import org.devio.`as`.proj.main.R import org.devio.`as`.proj.main.model.DetailModel import org.devio.`as`.proj.main.model.GoodsModel import org.devio.`as`.proj.main.route.HiRoute import org.devio.hi.library.util.HiStatusBar import org.devio.hi.ui.item.HiAdapter /** * 商品详情页 */ @Route(path = "/detail/main") class DetailActivity : HiBaseActivity() { private lateinit var viewModel: DetailViewModel private var emptyView: EmptyView? = null @JvmField @Autowired var goodsId: String? = null /*此字段是用来提前加载商品轮播及基础信息用的*/ @JvmField @Autowired var goodsModel: GoodsModel? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) HiStatusBar.setStatusBar(this, true, statusBarColor = Color.TRANSPARENT, translucent = true) HiRoute.inject(this) assert(!TextUtils.isEmpty(goodsId)) { " goodsId must bot be null" } setContentView(R.layout.activity_detail) initView() queryDetailData() } private fun initView() { action_back.setOnClickListener { onBackPressed() } action_share.setOnClickListener { showToast("share,not support for now.") } /* 这里的Grid全局设置成2列 */ recycler_view.layoutManager = GridLayoutManager(this, 2) recycler_view.adapter = HiAdapter(this) } private fun queryDetailData() { viewModel = DetailViewModel.get(goodsId, this) viewModel.queryDetailData().observe(this, Observer { if (it == null) { showEmptyView() } else { bindData(it) } }) } private fun bindData(detailModel: DetailModel) { TODO("Not yet implemented") } //还是采用动态添加的方式来实现空View,因为大多数情况下是用不到的,为了提高布局性能 private fun showEmptyView() { if (emptyView == null) { emptyView = EmptyView(this) emptyView!!.setIcon(R.string.if_empty3) emptyView!!.setDesc(getString(R.string.list_empty_desc)) emptyView!!.layoutParams = ConstraintLayout.LayoutParams(-1, -1) emptyView!!.setBackgroundColor(Color.WHITE) emptyView!!.setButton(getString(R.string.list_empty_action), View.OnClickListener { viewModel.queryDetailData() }) root_container.addView(emptyView) } recycler_view.visibility = View.GONE emptyView!!.visibility = View.VISIBLE } }
基于HiBanner+HiDataItem实现列表主图轮播:
接下来则来绑定数据到RecyclerView上面了。
1、准备商品轮播头部Item布局:
由于商品头部又有相对嵌套的关系,为了减少布局层级还是使用约束根布局来搭建,具体布局细节就不过多描述了,只针对关键点进行说明:
<?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginBottom="@dimen/dp_10" android:background="@color/color_white" android:paddingBottom="@dimen/dp_10"> <org.devio.hi.ui.banner.HiBanner android:id="@+id/hi_banner" android:layout_width="match_parent" android:layout_height="360dp" app:autoPlay="true" app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintTop_toTopOf="parent" app:loop="true" tools:background="@color/colorAccent" /> <TextView android:id="@+id/price" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginLeft="@dimen/dp_10" android:layout_marginTop="@dimen/dp_20" android:textColor="@color/color_d43" android:textSize="@dimen/sp_14" android:textStyle="bold" app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintTop_toBottomOf="@+id/hi_banner" tools:text="¥100" /> <TextView android:id="@+id/sale_desc" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="@dimen/dp_20" android:layout_marginRight="@dimen/dp_10" android:textColor="@color/color_9b9" android:textSize="@dimen/sp_12" app:layout_constraintRight_toRightOf="parent" app:layout_constraintTop_toBottomOf="@+id/hi_banner" tools:text="已拼100件" /> <TextView android:id="@+id/title" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginLeft="@dimen/dp_10" android:layout_marginTop="6dp" android:layout_marginRight="@dimen/dp_10" android:ellipsize="end" android:maxLines="2" android:textColor="@color/color_000" android:textSize="14sp" android:textStyle="bold" app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintTop_toBottomOf="@+id/price" tools:text="移动端架构师成长体系课谁学谁知道\n\n移动端架构师成长体系课谁学谁知道" /> </androidx.constraintlayout.widget.ConstraintLayout>
预览如下:
2、绑定Item数据:
根据咱们之前https://www.cnblogs.com/webor2006/p/13607431.html封装的HiDataItem,新建一个对应的Item:
package org.devio.`as`.proj.main.biz.detail import android.widget.ImageView import kotlinx.android.synthetic.main.layout_detail_item_header.* import org.devio.`as`.proj.common.ui.view.loadUrl import org.devio.`as`.proj.main.R import org.devio.`as`.proj.main.model.DetailModel import org.devio.`as`.proj.main.model.SliderImage import org.devio.hi.ui.banner.core.HiBannerAdapter import org.devio.hi.ui.banner.core.HiBannerModel import org.devio.hi.ui.banner.indicator.HiNumIndicator import org.devio.hi.ui.item.HiDataItem import org.devio.hi.ui.item.HiViewHolder class HeaderItem( val sliderImages: List<SliderImage>?, val price: String?, val completedNumText: String?, val goodsName: String? ) : HiDataItem<DetailModel, HiViewHolder>() { override fun onBindData(holder: HiViewHolder, position: Int) { val context = holder.itemView.context ?: return val bannerItems = arrayListOf<HiBannerModel>() sliderImages?.forEach {//将其转换成Banner对应的Model val bannerMo = object : HiBannerModel() {} bannerMo.url = it.url bannerItems.add(bannerMo) } holder.hi_banner.setHiIndicator(HiNumIndicator(context)) holder.hi_banner.setBannerData(bannerItems) holder.hi_banner.setBindAdapter { viewHolder: HiBannerAdapter.HiBannerViewHolder?, mo: HiBannerModel?, position: Int -> val imageView = viewHolder?.rootView as? ImageView mo?.let { imageView?.loadUrl(it.url) } } } override fun getItemLayoutRes(): Int { return R.layout.layout_detail_item_header } }
其中目前报错了:
因为HiDataItem的构造中有一个参数需要赋值,看一下:
其它这个data参数木有用到,所以将其给一个默认的值既可
所以下而来搞一下:
然后再将剩一下的View进行数据绑定,没啥好说的:
package org.devio.`as`.proj.main.biz.detail import android.text.SpannableString import android.text.Spanned import android.text.TextUtils import android.text.style.AbsoluteSizeSpan import android.widget.ImageView import kotlinx.android.synthetic.main.layout_detail_item_header.* import org.devio.`as`.proj.common.ui.view.loadUrl import org.devio.`as`.proj.main.R import org.devio.`as`.proj.main.model.DetailModel import org.devio.`as`.proj.main.model.SliderImage import org.devio.hi.ui.banner.core.HiBannerAdapter import org.devio.hi.ui.banner.core.HiBannerModel import org.devio.hi.ui.banner.indicator.HiNumIndicator import org.devio.hi.ui.item.HiDataItem import org.devio.hi.ui.item.HiViewHolder class HeaderItem( val sliderImages: List<SliderImage>?, val price: String?, val completedNumText: String?, val goodsName: String? ) : HiDataItem<DetailModel, HiViewHolder>() { override fun onBindData(holder: HiViewHolder, position: Int) { val context = holder.itemView.context ?: return val bannerItems = arrayListOf<HiBannerModel>() sliderImages?.forEach { val bannerMo = object : HiBannerModel() {} bannerMo.url = it.url bannerItems.add(bannerMo) } holder.hi_banner.setHiIndicator(HiNumIndicator(context)) holder.hi_banner.setBannerData(bannerItems) holder.hi_banner.setBindAdapter { viewHolder: HiBannerAdapter.HiBannerViewHolder?, mo: HiBannerModel?, position: Int -> val imageView = viewHolder?.rootView as? ImageView mo?.let { imageView?.loadUrl(it.url) } } holder.price.text = spanPrice(price) holder.sale_desc.text = completedNumText holder.title.text = goodsName } /** * 设置价格的富文本 */ private fun spanPrice(price: String?): CharSequence { if (TextUtils.isEmpty(price)) return "" val ss = SpannableString(price) ss.setSpan(AbsoluteSizeSpan(18, true), 1, ss.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) return ss } override fun getItemLayoutRes(): Int { return R.layout.layout_detail_item_header } }
3、DetailActivity.bindData()添加到HiAdapter中:
其中标红的商品价格有情况需要说明一下,先看一下它们的数据形态:
marketPrice金额前面有¥符号,而groupPrice木有,而且marketPrice有可能为空,此时就应该显示成groupPrice,所以。。咱们需要封装一下价格的显示:
另外还需要预加载一下,对于有传goodsModel的情况下,这样可以一进界面就可以看到商品的头倍信息,增加用户体验:
这里有一个细节需要提一下:
因为在preBindData()时其布局都还没有完成,此时调用它recyclerView的notify()肯定就会抛异常的,看一下调用的方法就知道了:
4、增加跳转事件:
给首页Banner和商品列表增加一下跳转事件:
其中在ARouter中增加一个页面映射:
另外对于商品列表的点击也得加一下事件:
不过此时报错了,是因为GoodsModel木有实现Parcelable接口,不是往Bundle可以传序列号对象么?是的,但是!!这里是要学习一下在Koltin中使用Parcelable跟在Java中使用有啥不一样,感受一下:
其实在Kotlin中使用Parcelable非常之简单,只要再加一个注解既可:
然后就可以了,是不是超赞,回想一下Java中的写法,不要太清爽哦~~
5、运行:
报错了。。
其实是咱们字段定义没有允许为空造成,如下:
然后GoodsItem这块得判空了:
再运行: