简介

在上一篇文章中,使用“动画”方案来实现弹幕效果。容器控件是自定义的。每个弹幕都作为它的子控件,弹幕的初始位置放在容器控件右侧的外侧。每个弹幕都通过动画从右到左翻译通过屏幕。

该方案的性能需要改进。开启 GPU 渲染模式:

原因是容器控件预先构建了所有的弹幕视图,并将它们堆叠在屏幕的右侧。如果弹幕数据量大,容器控件会因为子视图太多而消耗大量measure+layout时间。

既然性能问题是提前加载不必要的弹幕造成的,那我们能不能只预加载有限数量的弹幕呢?

只加载有限数量的子视图的可滚动控件是 RecyclerView!它不是提前将Adapter中的所有数据都转换成View,而是只预加载一屏数据,然后随着滚动不断加载新的数据。

要想用RecyclerView实现弹幕效果,就得“自定义LayoutManager”。

自定义布局参数

自定义LayoutManager第一步:继承recyclerview LayoutManger:

1.class LaneLayoutManager: RecyclerView.LayoutManager() {

2.override fun generateDefaultLayoutParams(): RecyclerView.LayoutParams {}

3.}

4.复制代码

根据 Android studio 的提示,你必须实现一个 generateDefaultLayoutParams() 方法。用于生成自定义的 LayoutParams 对象,在布局参数中携带自定义属性。

当前场景不需要自定义布局参数,所以该方法可以实现如下:

1.override fun generateDefaultLayoutParams(): RecyclerView.LayoutParams {

2.return RecyclerView.LayoutParams(

3.RecyclerView.LayoutParams.WRAP_CONTENT,

4.RecyclerView.LayoutParams.WRAP_CONTENT

5.)

6.}

7.复制代码

表示recyclerview的LayoutParams。

初始填充弹幕

自定义 LayoutManager 最重要的一步是定义如何布局表格项。

对于 LinearLayoutManager,表格项在一个方向上线性分布。第一次显示列表时,表格项从上到下依次填充,称为“初始填充”。

对于 LaneLayoutManager,第一个填充是“将项目符号列表填充到列表末尾(在屏幕外不可见)”。

关于LinearLayoutManager如何填充表格项的源码分析,上一篇RecyclerView面试题 |滚动时如何填充或回收表格项目?经分析,引述如下结论:

  1. LinearLayoutManager 在 onLayoutChildren() 方法中布局表格条目。

2、布局表项的关键方法包括fill()和layoutChunk()。前者表示列表的填充动作,后者表示填充单个表项。

3.在填充动作中,通过while循环不断填充表条目,直到列表中的剩余空间用完。这个过程用伪代码表示如下:

1.public 类 LinearLayoutManager {

2.//布局表格项

3.public void onLayoutChildren() {

4.//填写表格条目

5.fill() {

6.while(列表还有空间){

  1. //填充单个表条目

8.layoutChunk(){

  1. //使表格条目成为子视图
  1. addView(视图)

11.}

12.}

13。}

14.}

15.}

16.复制代码

1、为了避免每次填满新的表项都重新创建视图,需要从RecyclerView缓存中获取表项视图,即调用recycler getViewForPosition()。这个方法的详细解释可以点击RecyclerView缓存机制|如何重用表条目?

看完源码了解原理后,弹幕布局可以建模如下:

1.class LaneLayoutManager : RecyclerView.LayoutManager() {

2.private val LAYOUT_FINISH u003d -1 //标记填充结束

3.private var adapterIndex u003d 0 //列出适配器索引

4 //弹幕纵向间距

  1. 差距 u003d

6.get() u003d field.dp

7.//Layout 8 override fun onLayoutChildren(recycler: RecyclerView.Recycler?, state: RecyclerView.State?)

9.{

10.fill(回收站)

11。}

12.//填表项

  1. 私人乐趣填充(recycler: RecyclerView.Recycler?)

14.{

15.//弹幕布局可用高度,即列表高度

16.var totalSpace u003d 高度 - paddingTop - paddingBottom

17.var remainSpace u003d totalSpace

18.//只要有足够的空间就继续填充表项

19.while (goOnLayout(remainSpace)) {

20.//填写单表条目 21 val consumeSpace u003d layoutView(recycler)

  1. if (consumeSpace u003du003d LAYOUT_FINISH) 中断
  1. //更新剩余空间

24.remainSpace -u003d consumeSpace

25.}

26.}

27。

28.//是否还有空间可以填充 # 和 # 是否还有更多数据 29 private fun goOnLayout(remainSpace: Int) u003d remainSpace > 0 && adapterIndex in 0 直到 itemCount

30。

  1. //填充单个表条目
  1. private fun layoutView(recycler: RecyclerView.Recycler?): Int {
  1.    // 1. 从缓存池中获取表项视图
    
  1.    //如果缓存未命中,触发#onCreateViewHolder()和#onBindViewHolder()
    
  1.    val view u003d recycler?.getViewForPosition(adapterIndex)
    
  1.   view ?: return LAYOUT\_FINISH //如果获取不到table item view,则填充结束
    
  1.    // 2. 将表格项视图变成列表 38. addView(view)39 // 3 测量项视图 40 measureChildWithMargins(view, 0, 0) 41 // 可用弹幕布局的高度,即列表高度为 42         var totalSpace u003d height - paddingTop - paddingBottom
    
  1.    //弹幕车道数,即垂直列表可以容纳几个弹幕44 Val。 laneCount u003d (totalSpace + gap) / (view.Measuredheight + gap)45 //计算当前table item所在的lane 46 Val index u003d adapterIndex% laneCount47 //计算当前的上下左右边框table item 48 val # left u003d width //弹幕左侧是列表右侧的49         val top u003d index \* (view.measuredHeight + gap)
    
  1.    val right u003d left + view.measuredWidth
    
  1.    val bottom u003d top + view.measuredHeight
    
  1.    // 4. 布局表格项目(此方法考虑项目装饰) 53        layoutDecorated(view, left, top, right, bottom)
    
  1.    val verticalMargin u003d (view.layoutParams as? RecyclerView.LayoutParams)?.let { it.topMargin + it.bottomMargin } ?: 0
    
  1.    //继续获取下一个表项视图
    
  1.    适配器索引++
    
  1.    //返回填充表格条目所消耗的像素值 58         return getDecoratedMeasuredHeight(view) + verticalMargin。
    
  1. }

60。}

61.复制代码

弹幕滚动的每条水平线称为“车道”。

车道从列表的顶部到底部垂直分布。列表高度 / 车道高度 u003d 车道数。

在fill()方法中,以“列表剩余高度>0”为循环条件,不断地将表格项填充到泳道中。它必须经过四个步骤:

1.从缓存池中获取表项视图

  1. 使表格项视图成为列表子项

3.测量表项查看

  1. 布局表项

经过这四个步骤,表格项相对于列表的位置就确定了,表格项的视图就已经渲染好了。

运行demo,果然~,我什么都没看到。。。

没有添加列表滚动逻辑,所以排列在列表右侧外侧的表格项仍然是不可见的。不过可以使用Android studio的Layout Inspector工具来验证第一次填充代码的正确性:

Layout Inspector 将使用线框来表示屏幕外的控件。如图所示,列表右侧的外侧填充了四个表格项。

自动滚动弹幕

为了查看填充的表格项目,您必须让列表自发滚动。

最直接的解决办法就是不断调用recyclerview smoothScrollBy()。为此,为 Countdown 编写了一个扩展方法:

1.fun <T> 倒计时( .

  1. duration: Long, //总倒计时时间
  1. interval: Long, //倒计时间隔
  1. onCountdown: suspend (Long) -> T //倒计时回调

5.): 流量<T> u003d

  1. 流 { (持续时间 - 间隔 向下到 0 步 间隔).forEach { emit(it) } }
  1.    .onEach { 延迟(间隔) }
    
  1.    .onStart { emit(duration) }
    
  1.    .map { onCountdown(it) }
    
  1.    .flowOn(Dispatchers.Default)
    

11.复制代码

使用Flow构造一个异步数据Flow,每次都会发出倒计时的剩余时间。关于 Flow 的详细解释,请点击 Kotlin 异步 | Flow应用场景及原理

然后你可以像这样自动滚动列表:

1.countdown(Long.MAX_VALUE, 50) {

  1. recyclerView.smoothScrollBy(10, 0)

3.}.launchIn(MainScope())

4.复制代码

每 50 毫秒向左滚动 10 个像素。效果如下图所示:

弹幕连续填充

因为只进行了初始填充,即每条车道只填充一个表格项,所以第一行的表格项滚入屏幕后不会有后续弹幕。

LayoutManger.onLayoutChildren() 只会在第一次布局列表时调用一次,即弹幕的初始填充只会执行一次。为了连续显示弹幕,表格条目必须在滚动时连续填充。

以前的 RecyclerView 面试问题 |滚动时如何填充或回收表格项目?在分析了列表滚动时不断填充表格项的源码后,引用如下结论:

1、在滚动之前,RecyclerView会根据预期的滚动位移来决定列表中需要填充多少个新表格项。

2、源码上显示,即在scrollVerticallyBy()中调用fill()填充表项:

1.public 类 LinearLayoutManager {

  1. @Override 3. public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) { 4. return scrollBy(dy, recycler, state);
  1. }

6。

  1. int scrollBy(int delta, RecyclerView.Recycler recycler, RecyclerView.State state) {
  1.   ...
    
  1.   //填写表项 10        final int consumed u003d mLayoutState.mScrollingOffset + fill(recycler, mLayoutState, state, false);
    
  1.   ...
    
  1. }

13.}

14.复制代码

对于弹幕场景,也可以写一个类似的:

1.class LaneLayoutManager : RecyclerView.LayoutManager() { 2.override fun scrollHorizontallyBy(dx: Int, recycler: RecyclerView.Recycler?, state: RecyclerView.State?): Int {

  1.    return scrollBy(dx, recycler)
    
  1. }

5。

  1. 覆盖 fun canScrollHorizontally(): 布尔 {
  1.    return true //表示列表可以水平滚动
    
  1. }

9.}

10.复制代码

覆盖 canScrollHorizontally() 返回 true 以指示列表可以水平滚动。

RecyclerView的滚动是逐段进行的,每一段的位移都会通过scrollhorizontalyby()传递。通常,在这种方法中,会根据位移填充新的表格项,然后触发列表的滚动。列表滚动的源码分析可以点击RecyclerView。滚动是如何实现的? (1) |解锁阅读源码新姿势。

scrollBy() 封装了基于滚动连续填充表格条目的逻辑。 (稍后分析)

表格项连续填充的逻辑比第一次填充要复杂一点。第一次填充,只需按照泳道从上到下展开表格项目,并填充列表的高度即可。对于连续填充,需要根据滚动距离(无弹幕显示的车道)计算出即将耗尽的车道,对于已耗尽的车道仅填写表格项。

为了快速获取耗尽车道,必须抽象出一个“车道”结构来保存车道的滚动信息:

1.//车道

2.数据类车道(

  1. var end: Int, //泳道尽头弹幕横坐标
  1. var endLayoutIndex: Int, //车道末端弹幕的布局索引
  1. var startLayoutIndex: Int //泳道头弹幕的布局索引

6.)

7.复制代码

泳道结构包含三个数据:

1、车道尽头弹幕横坐标:是车道最后一个弹幕的右值,即其右侧与RecyclerView左侧的距离。该值用于判断经过一段时间的滚动位移后车道是否会干涸。

2、车道尽头弹幕布局索引:是车道最后一个弹幕的布局索引。记录下来,通过getChildAt()轻松获取车道上最后一个弹幕的视图。 (布局索引和适配器索引不同,RecyclerView只持有有限数量的表项,所以布局索引的取值范围为[0,x],X的取值略大于一屏幕表项。对于弹幕,适配器索引的值为[0,∞])

  1. 车道首部弹幕布局指数:类似2,方便获取车道首部弹幕的视野。

借助泳道结构,我们第一次要重构填表项的逻辑:

1.class LaneLayoutManager : RecyclerView.LayoutManager() {

  1. //初始填充时最后填充的弹幕
  1. 私有变量 lastLaneEndView:查看? u003d 空
  1. //所有车道
  1. private var lanes u003d mutableListOf<Lane>()
  1. //弹幕的初始填充 7 override fun onLayoutChildren(recycler: RecyclerView.Recycler?, state: RecyclerView.State?) { 8. fillLanes(recycler, lanes)
  1. }
  1. //骑车填充弹幕 11 private fun fillLanes(recycler: RecyclerView.Recycler?, lanes: MutableList<Lane>) {
  1.    lastLaneEndView u003d null
    
  1.    //如果列表垂直方向还有空间,继续补弹幕 14         while (hasMoreLane(height - lanes.bottom())) {
    
  1.        //将单个弹幕填充到第 16 车道             val consumeSpace u003d layoutView(recycler, lanes)
    
  1.        如果 (consumeSpace u003du003d LAYOUT\_FINISH) 中断
    
  1.    }
    
  1. }
  1. //填充单条弹幕并记录车道信息 21 private fun layoutView(recycler: RecyclerView.Recycler?, lanes: MutableList<Lane>): Int {
  1.    val view u003d recycler?.getViewForPosition(adapterIndex)
    
  1.    查看?:返回 LAYOUT\_FINISH
    
  1.    measureChildWithMargins(view, 0, 0)
    
  1.    val verticalMargin u003d (view.layoutParams as? RecyclerView.LayoutParams)?.let { it.topMargin + it.bottomMargin } ?:26       val consumed u003d getDecoratedMeasuredHeight(view) + if (lastLaneEndView u003du003d null) 0 else verticalGap+ verticalMargin27        //如果列表的垂直方向可以容纳新的lane,则创建新lane,否则停止填充 28} if (height - lanes.bottom() - consume > 0) {29} lanes.add(emptyLane (adapterIndex))30} else} return LAYOUT\_FINISH3132 # addView(view)33 //获取新添加的lane 34 # val # Lane u003d lanes Last() 35 //计算上、下、左、右帧barrage 36 # val # left u003d 车道末端 + horizontalGap37        val top u003d if (lastLaneEndView u003du003d null) paddingTop else lastLaneEndView!!.bottom + verticalGap38        val right u003d left + view.measuredWidth39        val bottom u003d 顶部+ 视图measuredhight40 //定位弹幕 41 , layoutDecorated(view, left, top, right, bottom)42 // 更新lane末端横坐标和layout index 43 ,lane Apply {44} end u003d right45} endLayoutIndex u003d childCount - 1 //因为是新添加的表项,它的索引值必须是最大的 46} 4748} adapterIndex++49} lastlaneendview u003d view50} return} consume51} 52}
    

53.复制代码

弹幕的初始填充也是在垂直方向不断增加车道的过程。判断是否添加的逻辑如下: List height - 当前底部 Lane 的底部值 - 本次弹幕填充消耗的像素值 > 0,其中 lanes Bottom() 是 list < Lane 的扩展方法>

1fun List<Lane>.bottom() u003d lastOrNull()?.getEndView()?.bottom ?: 02 复制代码

它获取车道列表中的最后一个车道,然后获取车道中最后一个弹幕视图的底部值。其中getEndView()定义为lane的扩展方法:

1class LaneLayoutManager : RecyclerView.LayoutManager() {

  1. 数据类 Lane(var end: Int, var endLayoutIndex: Int, var startLayoutIndex: Int)
  1. 私人乐趣Lane.getEndView():查看? u003d getChildAt(endLayoutIndex)

4.}

5.复制代码

理论上,“获取Lane中最后一个弹幕的视图”应该是Lane提供的方法。但是是不是不需要定义为 Lane 的扩展方法,也需要定义在 LaneLayoutManager 的内部呢?

如果定义在 Lane 内部,则无法在此上下文中访问 layoutmanager 如果 getchildat() 方法仅定义为 LaneLayoutManager 的私有方法,则无法访问 endLayoutIndex。所以这是整合两个语境。

回看滚动时不断填充弹幕的逻辑:

1.class LaneLayoutManager : RecyclerView.LayoutManager() { 2. 覆盖 fun scrollHorizontallyBy(dx: Int, recycler: RecyclerView.Recycler?, state: RecyclerView.State?): Int {

  1.    return scrollBy(dx, recycler)
    
  1. }
  1. //根据位移确定要填充多少表项 6 private fun scrollBy(dx: Int, recycler: RecyclerView.Recycler?): Int {
  1.    //如果列表没有子级或没有滚动,则返回
    
  1.    如果 (childCount u003du003d 0 || dx u003du003d 0) 返回 0
    
  1.    //在滚动开始前更新车道信息
    
  1.    updateLanesEnd(lanes)
    
  1.    //获取滚动绝对值
    
  1.    val absDx u003d abs(dx)
    
  1.    //遍历所有车道并用弹幕填充耗尽的车道
    
  1.    lanes.forEach { lane ->
    
  1.        if (lane.isDrainOut(absDx)) layoutViewByScroll(recycler, lane)
    
  1.   }
    
  1.    //滚动列表的立足点:将表格项平移到手指位移的反方向等距离
    
  1.    offsetChildrenHorizontal(-absDx)
    
  1.   返回 dx
    
  1. }

21.}

22.复制代码

滚动时不断填充弹幕的逻辑如下:

1.更新车道信息

  1. 用弹幕填满干涸的车道

3.触发滚动

其中,1和2发生在真正的滚动之前。在滚动之前,已经获得了滚动位移。根据排量,可以计算出滚动后会耗尽的车道:

1//车道是否干燥

2.private fun Lane.isDrainOut(dx: Int): Boolean u003d getEnd(getEndView()) - dx < width

3.//获取表项的{right}值

4.private fun getEnd(view: View?) u003d

  1. 如果(查看 u003du003d null) Int.MIN_VALUE
  1. else getDecoratedRight(view) + (view.layoutParams as RecyclerView.LayoutParams).rightMargin

7.复制代码

车道耗尽的判断依据是:车道最后一个弹幕右侧是否小于向左移动dx后的列表宽度。如果小于,则说明车道内的弹幕已经充分展现。这时候继续补弹幕:

1.//在滚动时填充一个新的弹幕

2.private fun layoutViewByScroll(recycler: RecyclerView.Recycler?, lane: Lane) { 3. val view u003d recycler?.getViewForPosition(adapterIndex) 4 view ?: return 5. measureChildWithMargins(view, 0, 0) 6 addView(view ) 7 8 val left u003d lane.end + horizontalGap 9. val top u003d lane.getEndView()?.top ?: paddingTop

  1. val right u003d left + view.measuredWidth
  1. val 底部 u003d 顶部 + view.measuredHeight
  1. layoutDecorated(视图,左,上,右,下)
  1. lane.apply {
  1.    结束 u003d 正确
    
  1.    endLayoutIndex u003d childCount - 1
    
  1. }
  1. adapterIndex++18}19 复制代码

填充逻辑与第一次填充几乎相同。唯一不同的是,滚动时的填充由于空间不足无法提前返回,因为是通过寻找正确的lane来填充的。

为什么要在填充耗尽的车道之前更新车道信息?

1.//更新车道信息

2.private fun updateLanesEnd(lanes: MutableList<Lane>) {

  1. lanes.forEach { lane ->
  1.    lane.getEndView()?.let { lane.end u003d getEnd(it) }
    
  1. }

6.}

7.复制代码

因为 RecyclerView 的滚动是逐段进行的,所以好像滚动了一段丢失的距离。 Scrollhorizontalyby() 可能要回调十次以上。每次回调,弹幕会前进一小段,即Lane末端的弹幕横坐标会发生变化,同时在Lane结构中也要改变。否则,Lane depletion 的计算就会出错。

无限滚动弹幕

初次连续充填后,弹幕可以顺利卷起。如何让唯一的弹幕数据无限循环?

只需在 Adapter 上做一个小手脚:

1.class LaneAdapter : RecyclerView.Adapter<ViewHolder>() {

  1. //数据集
  1. private val dataList u003d MutableList()
  1. 覆盖乐趣 getItemCount(): Int {
  1.    //将表项设置为无穷大
    
  1.    返回 Int.MAX\_VALUE
    
  1. }

8。

  1. 覆盖乐趣 onBindViewHolder(holder: ViewHolder, position: Int) {
  1. Val RealIndex u003d位置%datalist.size11 ... 12} 1314覆盖fun inbindViewHolder(持有人:viewholder,位置,位置:int,有效载荷:mutableLelist <any>){15 val realIndex u003d position u003d position%datalist.size16 ... 17 ... 17 ... 17 ... 17 }18}19 复制代码

将列表的数据量设置为无穷大。在创建表项视图并为其绑定数据时,取适配器索引的模块。

恢复弹幕

最后剩下的问题是如何恢复弹幕。如果没有回收,对不起RecyclerView这个名字。

回收表项的入口在LayoutManager中定义:

1.public void removeAndRecycleView(View child, @NonNull Recycler recycler) {

  1. removeView(child);
  1. recycler.recycleView(child);

4.}

5.复制代码

回收逻辑最终将委托给 Recycler。回收表项的源码分析,点击以下文章:

  1. RecyclerView缓存机制 |什么是回收的?

  2. RecyclerView缓存机制 |在哪里回收?

  3. RecyclerView动画原理 |换个姿势看源码(预排版)

  4. RecyclerView动画原理 |前布局、后布局和暂存缓存的关系

  5. RecyclerView 面试题 |什么情况下表项会被回收到缓存池中?

对于弹幕场景,弹幕什么时候才能恢复?

当然,弹幕滚出屏幕的那一刻!

我们如何捕捉这一刻?

当然是用每次滚动前的位移来计算的!

滚动的时候,除了不断的填充弹幕,我们还要不断的循环弹幕(源码里是这么说的,我只是抄袭而已):

1.private fun scrollBy(dx: Int, recycler: RecyclerView.Recycler?): Int { 2. if (childCount u003du003d 0 || dx u003du003d 0) return 0

  1. updateLanesEnd(lanes)
  1. val absDx u003d abs(dx)
  1. //弹幕持续填充
  1. lanes.forEach { lane ->
  1.    if (lane.isDrainOut(absDx)) layoutViewByScroll(recycler, lane)
    
  1. }
  1. //弹幕持续恢复
  1. recycleGoneView(lanes, absDx, recycler)
  1. offsetChildrenHorizontal(-absDx)

12

返回 dx

13.}

14.复制代码

这是scrollBy() 的完整版本。滚动时,先填充,然后立即回收:

1.fun recycleGoneView(lanes: List<Lane>, dx: Int, recycler: RecyclerView.Recycler?) {

  1. 回收商?:退货
  1. //穿越车道
  1. lanes.forEach { lane ->
  1.    //获取车道头部弹幕
    
  1.    getChildAt(lane.startLayoutIndex)?.let { startView ->
    
  1.        //如果车道头部弹幕滚出屏幕,回收
    
  1.       如果 (isGoneByScroll(startView, dx)) {
    
  1.            //恢复弹幕视图
    
  1.            removeAndRecycleView(startView, recycler)
    
  1.            //更新车道信息
    
  1.            updateLaneIndexAfterRecycle(lanes, lane.startLayoutIndex)
    
  1.           lane.startLayoutIndex +u003d lanes.size - 1
    
  1.        }
    
  1.    }
    
  1. }

17.}

18.复制代码

回收和填充一样,也是通过遍历找到消失的弹幕,进行回收。

判断弹幕消失的逻辑如下:

1.fun isGoneByScroll(view: View, dx: Int): Boolean u003d getEnd(view) - dx < 0

2.复制代码

如果向左平移dx后弹幕右侧小于0,则表示弹幕已经滚出列表。

弹幕恢复后,会从 RecyclerView 中分离出来。该操作会影响列表中其他弹幕的布局索引值。就像删除数组中的一个元素一样,所有后续元素的索引值都会减一:

1.fun updateLaneIndexAfterRecycle(lanes: List<Lane>, recycleIndex: Int) {

  1. lanes.forEach { lane ->
  1.    如果 (lane.startLayoutIndex > recycleIndex) {
    
  1.        lane.startLayoutIndex--
    
  1.    }
    
  1.    如果 (lane.endLayoutIndex > recycleIndex) {
    
  1.        lane.endLayoutIndex--
    
  1.    }
    
  1. }

10.}

11.复制代码

遍历所有车道。只要车道头的弹幕布局指数大于回收指数,就会减一。

性能

再次开启 GPU 渲染模式:

体验非常流畅,直方图没有超过警戒线。

talk 很便宜,给我看代码

完整代码,点击这里在这个repo中搜索LaneLayoutManager。

总结

之前花了很多时间看源码,还产生了“看源码这么费时间,有什么用?”这样的疑惑。这种性能优化是一个很好的回应。因为看过RecyclerView的源码,它的思路和解决问题的方法都在我脑海中种下。当弹幕的性能出现问题时,这颗种子就会发芽。有很多解决方案。什么样的种子在头上,就会长出什么样的芽。所以源码就是播种。虽然不能马上发芽,但总有一天会结果。

Logo

学AI,认准AI Studio!GPU算力,限时免费领,邀请好友解锁更多惊喜福利 >>>

更多推荐