Flutter ListView 是如何滚动的?[通俗易懂]

Flutter ListView 是如何滚动的?[通俗易懂]在我写这篇文章之前,如果你问我这个问题,那么我会回答:我管它怎么滚呢,能滚就行!既然写了这篇文章,那么我的回答(意味深长)是:它是这么滚的。。。 1. 先把你唬住 众所周知 Flutter frame

在我写这篇文章之前,如果你问我这个问题,那么我会回答:我管它怎么滚呢,能滚就行!既然写了这篇文章,那么我的回答(意味深长)是:它是这么滚的。。。

1. 先把你唬住

众所周知 Flutter framework 里有三颗树,widget tree,element tree,renderobject tree。那么我们今天来讲一下 layer tree。(WTF?)

1.1 为什么要讲 layer tree ?

我们知道 flutter 中真正负责绘制的工作是由 renderobject 完成的,那么最后绘制到哪里去了,显然是 layer tree 了。在调试 APP 的过程中 我们可以通过 debugDumpLayerTree()方法,在控制台打印信息,查看最后生成的 layer tree。

Flutter ListView 是如何滚动的?[通俗易懂]

1.2 layer 的分类

Flutter ListView 是如何滚动的?[通俗易懂]

layer 的种类也不算很多,我们说几个比较常见的 layer,其余的 layer 读者可以查看文档自行理解。

TransformLayer 继承自 OffsetLayer 是 layer tree 的根节点,layer 如其名,主要做矩阵变换。

OffsetLayer 继承自 ContainerLayer,可以包含子节点,主要做偏移用。

PictureLayer,TexttureLayer 这两个 layer 主要是负责真正绘制展示内容的,一个是展示绘制的颜色、边框、图像、形状之类的 layer,另一个负责绘制文本。

ClipRectLayer 负责裁剪子 layer。

1.3 layer 什么时候上场的?

下边说。

好吧,很简单,你应该没被吓到。😣

以上的东西暂时不重要,可以先放下了。🤣

2. 第一板斧

滚动可以说是动画的一种形式,就是利用视觉暂留,不断更新一个视图的位置,从而达到滚动效果。为了搞清楚 ListView 是如何滚动的,我们先研究一下比较简单的一种滚动。

2.1 SingleChildScrollView 是如何滚动的?

想知道 SingleChildScrollView 是如何滚动的,很简单,既然知道滚动的基本原理,那就去找代码实现并验证了。(上源码)

先看 widget 层面

class SingleChildScrollView extends StatelessWidget {
    ...
    @overrideWidget build(BuildContext context) {    
        final Scrollable scrollable = Scrollable(  
        dragStartBehavior: dragStartBehavior,  
        axisDirection: axisDirection,  
        controller: scrollController,  
        physics: physics,  
        restorationId: restorationId,  
        viewportBuilder: (BuildContext context, ViewportOffset offset) {       
            return _SingleChildViewport(      
                axisDirection: axisDirection,      
                offset: offset,      
                child: contents,      
                clipBehavior: clipBehavior, 
            );  
        },);    
        retrun scrollable;    // 源码不是这样
    }
    ...
}

看重点 SingleChildScrollView 是个 StatelessWidget 它的 build 方法里 我们看到了 Scrollable 还有 viewportBuilder。我们先去看看 Scrollable。

Scrollable 是个 StatefulWidget 那么我们直接看对应 state 的 build 方法。

class ScrollableState ... {
    ...
    @overrideWidget build(BuildContext context) {        
        Widget result = _ScrollableScope(  
            scrollable: this,  
            position: position,  // TODO(ianh): Having all these global keys is sad. 
            child: Listener(    
                onPointerSignal: _receivedPointerSignal,    
                child: RawGestureDetector(      
                    key: _gestureDetectorKey,      
                    gestures: _gestureRecognizers,      
                    behavior: HitTestBehavior.opaque,      
                    excludeFromSemantics: widget.excludeFromSemantics,      
                    child: Semantics(        
                        explicitChildNodes: !widget.excludeFromSemantics,        
                        child: IgnorePointer(          
                            key: _ignorePointerKey,          
                            ignoring: _shouldIgnorePointer,          
                            ignoringSemantics: false,          
                            child: widget.viewportBuilder(context, position),        
                        ), 
                    ), 
                ), 
            ),
        );

        return result;    // 源码不是这样的
    }
    ...
}

到这里我们看到 RawGestureDetector (惊喜),这不是就是处理手势识别的么,然后我们注意到最内部 Widget 里的 child 调用了 widget.viewportBuilder() 方法,这里有一个重要的参数 position(context:我也很重要啊!)。

 好了,我们该去看看 viewportBuilder 了。在第一个代码块里我们看到 viewportBuilder 里返回了_SingleChildViewPort,我们跟进去看看发现它继承 SingleChildRenderObjectWidget 说明重点不在这里,应该在对应的 RenderObject 里,看下源码它确实没干啥事,就创建、更新了下对应的 RenderObject。那么我们看看对应的 RenderObject 吧。

这里是 RenderObject 了。(好吧,我过去常常迷失在 widget 和 renderObject 中)

class _RenderSingleChildViewport extends RenderBox ... {
    ...
    @overridevoid attach(PipelineOwner owner) {  
        super.attach(owner);  
        _offset.addListener(_hasScrolled);
    }
    
    @overridevoid detach() {  
        _offset.removeListener(_hasScrolled);  
        super.detach();
    }

    void _hasScrolled() {  
        markNeedsPaint();
        ...
    }

    @overridevoid paint(PaintingContext context, Offset offset) {  
        if (child != null) {    
            final Offset paintOffset = _paintOffset;    
            void paintContents(PaintingContext context, Offset offset) {      
                context.paintChild(child, offset + paintOffset);    }    
            if (_shouldClipAtPaintOffset(paintOffset) && clipBehavior != Clip.none) { 
                 context.pushClipRect(needsCompositing, offset, Offset.zero & size, paintContents, clipBehavior: clipBehavior);    
            } else {      
                paintContents(context, offset);    
            }  
        }
    }
    ...
}

我们分析一下 _RenderSingleChildViewPort。这里看到有个 _offset 字段,前边提到了有个重要的参数 position 还记的吧,这个 _offset 它其实就是 position (换个马甲,我也认得你!)。敏感的我发现这两个变量类型不同([机智]),那就看看 ScrollPosition 到底是什么:

Flutter ListView 是如何滚动的?[通俗易懂]

诶,ScrollPosition  的爹是 ViewportOffset (_offset 的类型) 并且继承自 ChangeNotifier 难怪它可以添加 listener。

我们知道 RenderObject 在构建 render tree 的时候会进行 attach 操作,看源码 _offset 在这里添加了一个监听 _hasScrolled 来监听滚动,触发重绘(markNeedsPaint())。

继续看 paint 方法,paint 方法本身有个 offset 参数,同时内部记录了一个 paintOffset 变量。offset 是它爹给他的,应该是它本身的偏移量,那么 paintOffset 应该就是子 RenderObject 的偏移量吧。后边代码也很简单,就是判断和绘制。根据判断条件方法名大致可以猜到是:如果需要裁剪偏移,就要裁剪绘制什么的,否则直接绘制。根据 SingleChildScrollView 的表现,理解应该是,当内部可滚动 widget 视图滚出 SingleChildScrollView 的范围时,需要裁剪绘制,当滚动内容不够大到需要滚动时,直接绘制。

好了,SingleChildScrollView 的滚动原理到这里就讲清楚了,读者可以验证下。

3. 第二板斧

3.1 猜想 ListView 的滚动。

滚动基本原理我已烂熟于心,并且通过 SingleChildScrollView 的源码及实践的到了验证,ListView 的滚动原理不是 so easy?

为了验证 ListView的滚动,我自己写了个 widget 及 renderObject (copy 了一下 _RenderColoredBox 😏),在 paint 方法中打印了日志,期待在我滑动 ListView 的过程中能打印点什么。but nothing happens!好吧,它其实打印了一次,仅仅一次而已。

我感觉我的感情受到了欺骗!(不要惹怒一个认真的人)

一定有什么地方出了问题。那么从哪里开始出了问题?

手指触摸屏幕移动产生滑动,GuestureDetecture 应该没有问题。监听手势滑动,计算 position 应该也是必须的,position 是 ChangeNotifier 发生变化时会发出通知。应该也没问题。通知发出后会触发重绘,有问题,没有重绘!所以我们要看看渲染 ListView 的RenderObject 中给 position(_offset) 添加监听的地方做了什么事情。

根据前边的经验我们可以直接找到渲染 ListView 对应的 RenderObject – RenderShrinkWrappingViewPort / RenderViewPort 不过这两个 RenderObject 里并没有 attach 方法,所以我们得去它们的爹那里看看

again 这里是 RenderObject

abstract class RenderViewportBase {
    ...
    @overridevoid attach(PipelineOwner owner) {  
        super.attach(owner);  
        _offset.addListener(markNeedsLayout);
    }
    ...
    @overridevoid paint(PaintingContext context, Offset offset) {  
        if (firstChild == null)    
            return;  
        if (hasVisualOverflow && clipBehavior != Clip.none) {    
            context.pushClipRect(needsCompositing, offset, Offset.zero & size, _paintContents, clipBehavior: clipBehavior);  
        } else {    
            _paintContents(context, offset);  
        }
    }

    void _paintContents(PaintingContext context, Offset offset) {  
        for (final RenderSliver child in childrenInPaintOrder) {    
            if (child.geometry!.visible)      
                context.paintChild(child, offset + paintOffsetOf(child));  
        }
    }    ...
    @protecteddouble layoutChildSequence({...}) {
        ...
        while (child != null) {
            ...
            child.layout(SliverConstraints(...));
            ...
        }
        ...
    }
}

这里我把相关主要方法摘了出来,可以看到 _offset(position) 添加的监听果然有问题,没有标记需要绘制,而是标记需要布局了。好吧,我们要看看 layout 了。

我们看 RenderViewPort 吧,搞定参数多的,复杂的,简单的就不怕了。

class RenderViewport extends RenderViewportBase<SliverPhysicalContainerParentData> {
    ...
    @overridevoid performLayout() {
        ...
        double correction;
        int count = 0;        
        do {
            ...
            correction = _attemptLayout(mainAxisExtent, crossAxisExtent, offset.pixels + centerOffsetAdjustment);            
            ...
        } while (count < _maxLayoutCycles);    // static const int _maxLayoutCycles = 10; 
        ...
    }
    ...
    double _attemptLayout(double mainAxisExtent, double crossAxisExtent, double correctedOffset) {
        ...
        // positive scroll offsets
        return layoutChildSequence(...);
    }
}

我们知道 layout 过程实际会调用 performLayout 方法,然后重点就是两个方法调用,然后跑到父类 layoutChildSequence 方法了。看 RenderViewPortBase 的源码,调用了 child.layout 方法,所以,我们要看下 child 是如何布局的。。。

烦死了,我不要听了。

我不就想知道 ListView 为什么会滚动吗,还没到绘制 paint 阶段,你先把我在 layout 阶段搞蒙了。而且我也不知道 child 是什么。。。,我觉得就是 paint 搞得鬼! [😤]

好吧,我们去看 paint [耐心][折磨]

paint 是在父类 RenderViewPortBase 里实现的,我们去看看。代码在上边倒数第二个代码块。

[轻松]熟悉的感觉来了。paint 方法里做了个裁剪判断,然后调用 _paintContents ,_paintContents 又调用了 context.paintChild 方法,去做绘制,那我们就看看 PaintContext 的 paintChild 方法吧。

void paintChild(RenderObject child, Offset offset) {
    ...
    if (child.isRepaintBoundary) {  
        stopRecordingIfNeeded();  
        _compositeChild(child, offset);
    } else {  
        child._paintWithContext(this, offset);
    } 
    ...
}

void _compositeChild(RenderObject child, Offset offset) {
    if (child._needsPaint) {  
        repaintCompositedChild(child, debugAlsoPaintedParent: true);
    }
    final OffsetLayer childOffsetLayer = child._layer as OffsetLayer;
    childOffsetLayer.offset = offset;
    appendLayer(child._layer!); 
}

我们看到 paintChild 方法里很简单,做个判断,调用方法,鉴于 isRepaintBoundary 好像不熟,咱们就看 else ,跟进去到了 RenderObject 本类

abstract class RenderObject extends AbstractNode with DiagnosticableTreeMixin ... {
    ...
    void _paintWithContext(PaintingContext context, Offset offset) {
        ...
        paint(context, offset);
        ...
    }
    ...
}

在 RenderObject 里的 _paintWithContext 方法里直接调用了 paint 方法!如果每次都直接调用 paint 方法,为什么我的 paint 方法不打印日志!

走错了,再来。

所以就是 child.isRepaintBoundary 为 true,然后停止录制、组合 child,看看 _compositeChild 吧。我发现了让我激动地东西,OffsetLayer!这东西前边说了,是做偏移用的,滚动不就是偏移嘛!我有一个大胆的想法 😏

ListView widget 在 item 首次渲染时会调用 paint 方法,将自己绘制到图层上,然后滚动的时候只是去调整 OffsetLayer 的偏移!

4. 第三板斧

好了,到这里你已经知道 ListView 是如何滚动的了,你确信吗!

4.1 确认,ListView 就是这么滚动的

这里偷点懒,widget 部分不上源码了,读者可以自行查看,我说下重点。

ListView 继承自 BoxScrollView,BoxScrollView 继承自 ScrollView。ScrollView 是个 StatelessWidget,它的 build 方法构造出 Scrollable,包含一个 viewPortBuilder。buildViewPort 方法有一个参数 slivers,由声明在 Scrollable 类里,在 BoxScrollView 里实现的 buildSlivers 方法返回。buildSlivers 方法,调用了声明在 BoxScrollView 里,在 ListView(在这里是) 实现的 buildChildLayout 方法 。buildChildLayout 方法里返回了 SliverList 或 SliverFixedExtentList。这里说 SliverList,SliverList createRenderObject 返回 RenderSliverList。

这里正好书接上回,我们讲讲 RenderSliverList。哪回?layout 懵逼那回!并不是白讲的!

我们知道构建 ViewPort 的时候传递了参数 slivers,这里的 slivers 就是 [RenderSliverList]。在 build 构建过程中,RenderObject tree 构成时,作为 RenderViewPort 的子节点插入树中。所以layoutChildSequence 里调用的 child.layout 就是 RenderSliverList 对应的 layout 方法。然后就到了 performLayout。

class RenderSliverList extends RenderSliverMultiBoxAdaptor {
  ...
  @override
  void performLayout() {
    ...
    geometry = SliverGeometry(  
      scrollExtent: estimatedMaxScrollOffset,  
        paintExtent: paintExtent,  
        cacheExtent: cacheExtent,  
        maxPaintExtent: estimatedMaxScrollOffset, 
        // Conservative to avoid flickering away the clip during scroll. 
        hasVisualOverflow: endScrollOffset > targetEndScrollOffsetForPaint || constraints.scrollOffset > 0.0,);
    ...
  }
  ...
}

这个 performLayout 方法将近300行代码,着实让人头痛(虽然有很多注释)。这里其中一个重点就是这个 geometry,Geometry 主要是用来描述 RenderSliver(List) 占用的空间的。

layout 完了,我们该看看 paint 了。这里是 RenderSliverList 的父类。

abstract class RenderSliverMultiBoxAdaptor extends RenderSliver ... {
    ...
    @overridevoid paint(PaintingContext context, Offset offset) {
        ...
        Offset mainAxisUnit, crossAxisUnit, originOffset;
        bool addExtent;
        switch (applyGrowthDirectionToAxisDirection(constraints.axisDirection, constraints.growthDirection)) {  
            case AxisDirection.up:    
                mainAxisUnit = const Offset(0.0, -1.0); 
                crossAxisUnit = const Offset(1.0, 0.0); 
                originOffset = offset + Offset(0.0, geometry!.paintExtent); 
                addExtent = true; 
            break;
            ...
        }
        
        RenderBox? child = firstChild;
        while (child != null) {  
            final double mainAxisDelta = childMainAxisPosition(child); 
            final double crossAxisDelta = childCrossAxisPosition(child); 
            Offset childOffset = Offset(    
                originOffset.dx + mainAxisUnit.dx * mainAxisDelta + crossAxisUnit.dx * crossAxisDelta,    
                originOffset.dy + mainAxisUnit.dy * mainAxisDelta + crossAxisUnit.dy * crossAxisDelta,  
            ); 
            if (addExtent)    
                childOffset += mainAxisUnit * paintExtentOf(child); 
            // If the child's visible interval (mainAxisDelta, mainAxisDelta + paintExtentOf(child))  
            // does not intersect the paint extent interval (0, constraints.remainingPaintExtent), it's hidden.  
            if (mainAxisDelta < constraints.remainingPaintExtent && mainAxisDelta + paintExtentOf(child) > 0) {    
                context.paintChild(child, childOffset); 
            }  
            child = childAfter(child);
        }
    }
    ...
}

这里可以看到 paint 方法,主要是根据主轴方向用 geometry 计算偏移量。然后循环绘制 child。

4.2 有点混乱?

我们大致知道了 RenderViewPort 的 paint 过程,也知道了 RenderSliverList 的 paint 过程,但是好像不太连贯,具体偏移是怎么做的?那我们就梳理一下 paint 过程。

从哪里说呢,先看个字段吧,我们前边走错路返回过一次,因为有个这个东西 isRepaintBoundary。这个字段很重要!它是负责重绘的!flutter app 页面是可以局部重绘的,重绘的起点就是 isRepaintBoundary 标记为 true 的 RenderObject。这里也经常会涉及到渲染性能优化。

Flutter ListView 是如何滚动的?[通俗易懂]

这个张图显示了一些重写了 isRepaintBoundary 的RenderObject,isRepaintBoundary 的默认值是 false,重写当然就是返回 true 了。所以我们的 RenderViewPort 是一个“重绘边界”。

为了看着方便,我重新插入部分上边已经插入过的源码。

abstract class RenderViewportBase<ParentDataClass ... {
    ...
    @overridevoid paint(PaintingContext context, Offset offset) {  
        if (firstChild == null)    
            return;  
        if (hasVisualOverflow && clipBehavior != Clip.none) {    
            context.pushClipRect(needsCompositing, offset, Offset.zero & size, _paintContents, clipBehavior: clipBehavior);  
        } else {    
            _paintContents(context, offset);  
        }
    }

    void _paintContents(PaintingContext context, Offset offset) {  
        for (final RenderSliver child in childrenInPaintOrder) {    
            if (child.geometry!.visible)      
                context.paintChild(child, offset + paintOffsetOf(child));  
        }
    }
    ...
}

可以看到 RenderViewPort(Base) 的 paint 方法调用到了 context.paintChild 方法。

class PaintingContext extends ClipContext {
    ...
    void paintChild(RenderObject child, Offset offset) {
        if (child.isRepaintBoundary) {  
            stopRecordingIfNeeded();  
            _compositeChild(child, offset);
        } else {  
            child._paintWithContext(this, offset);
        }
    }

    void _compositeChild(RenderObject child, Offset offset) {
        // Create a layer for our child, and paint the child into it.
        if (child._needsPaint) {  
            repaintCompositedChild(child, debugAlsoPaintedParent: true);
        }

        final OffsetLayer childOffsetLayer = child._layer as OffsetLayer;
        childOffsetLayer.offset = offset;
        appendLayer(child._layer!);
    }
    ...
}

paintChild 这里的 child 就是 RenderSliverList 了。我们前边看了 framework 的 “ReapintBoundary”,没看到 RenderSliver 相关的东西,那他就不是。所以下边走 RenderSliverList 的 _paintWithContext 方法,这个方法是在 RenderObject 里实现的。

abstract class RenderObject extends AbstractNode with DiagnosticableTreeMixin ... {
    ...
    void _paintWithContext(PaintingContext context, Offset offset) {
        ...
        paint(context, offset);
        ...
    }
    ...
}

可以看到就是调用了 paint 方法。所以该看下 RenderSliverList 的 paint 方法了(在他爹那里)。重复插入代码。。。

abstract class RenderSliverMultiBoxAdaptor extends RenderSliver ... {
    ...
    @overridevoid paint(PaintingContext context, Offset offset) {
        ...
        Offset mainAxisUnit, crossAxisUnit, originOffset;
        bool addExtent;
        switch (applyGrowthDirectionToAxisDirection(constraints.axisDirection, constraints.growthDirection)) {  
            case AxisDirection.up:    
                mainAxisUnit = const Offset(0.0, -1.0); 
                crossAxisUnit = const Offset(1.0, 0.0); 
                originOffset = offset + Offset(0.0, geometry!.paintExtent); 
                addExtent = true; 
            break;
            ...
        }
        
        RenderBox? child = firstChild;
        while (child != null) {  
            final double mainAxisDelta = childMainAxisPosition(child); 
            final double crossAxisDelta = childCrossAxisPosition(child); 
            Offset childOffset = Offset(    
                originOffset.dx + mainAxisUnit.dx * mainAxisDelta + crossAxisUnit.dx * crossAxisDelta,    
                originOffset.dy + mainAxisUnit.dy * mainAxisDelta + crossAxisUnit.dy * crossAxisDelta,  
            ); 
            if (addExtent)    
                childOffset += mainAxisUnit * paintExtentOf(child); 
            // If the child's visible interval (mainAxisDelta, mainAxisDelta + paintExtentOf(child))  
            // does not intersect the paint extent interval (0, constraints.remainingPaintExtent), it's hidden.  
            if (mainAxisDelta < constraints.remainingPaintExtent && mainAxisDelta + paintExtentOf(child) > 0) {    
                context.paintChild(child, childOffset); 
            }  
            child = childAfter(child);
        }
    }
    ...
}

这里利用 geometry 计算 childOffset 偏移,来绘制 child。

这里回到 PaintingContext 了,源码参考上面。注意这里的 child 现在是 “item” 了。突然发现,有问题了!前边说 child 是 RepaintBoundary,但是我并不知道啊!好吧,系统帮你做了这件事情。**ListView 的 item 外边包裹了一个 RepaintBoundary。**所以我们走 _compositeChild。第一次肯定要绘制的,所以我们去看 repaintCompositedChild。

class PaintingContext extends ClipContext {
    ...
    static void repaintCompositedChild(RenderObject child, { bool debugAlsoPaintedParent = false }) {
        _repaintCompositedChild(  
            child,  
            debugAlsoPaintedParent: debugAlsoPaintedParent,
        );
    }

    static void _repaintCompositedChild(  
        RenderObject child, {  
        bool debugAlsoPaintedParent = false,  
        PaintingContext? childContext,
    }) {
        OffsetLayer? childLayer = child._layer as OffsetLayer?;
        if (childLayer == null) {
            child._layer = childLayer = OffsetLayer();
        }
        childContext ??= PaintingContext(child._layer!, child.paintBounds); 
        child._paintWithContext(childContext, Offset.zero); 
        childContext.stopRecordingIfNeeded();
    }
    ...
}

可以看到在 repaintCompositedChild 里,调用了 _repaintCompositedChild。这里会进行判断,如果 childLayer 为 null 就会为 child 创建新的 layer,这里是 OffsetLayer。然后调用 child._paintWithContext ,注意在 RenderObject._paintWithContext 里设置了 _needsPaint 为 false ,然后调用 paint 进行绘制

好了,小结一下。

ListView 的父 Widget,ViewPort 是“绘制边界”。每次 ListView 内相关内容发生变化,需要重绘时都从这里开始。RenderViewPort.paint -> … -> RenderSliverList.paint -> … -> PaintingContext._compositeChild。在 _compositeChild 里进行 layer tree 的构建。从上面分析知道,item(“RepaintBoundary”) 在首次需要绘制时,会创建属于自己的 layer,然后会先把 _needsRepaint 置为 false ,再调用 paint 方法进行绘制。后续 _compositeChild 过程中只对 layer(OffsetLayer) 设置偏移。

4.3 小疑问?

ListView 的子 Widget 滑出屏幕或者滑入屏幕时,是什么情况?

这个问题是在那300行 performLayout 里处理的。在那里会添加、删除子 RenderObject 计算新的 RenderObject 的偏移。

4.4 重要,ListView 复用机制分析

突然发现这个问题和滚动关系不大,另起一篇说明吧。

4.5 小提示

我们前边讲到 ListView 的 buildChildLayout 的时候,看到源码返回了 SliverList 或者 SliverFixedExtentList。从名字就可以区分出来,一个是“固定范围”的 List,一个不是。这里的“固定范围”指的是 itemExtent 即列表每个条目的范围(宽、高)是否是固定的。SliverFixedExtentList 由于 itemExtent 指定了 item 在主轴方向上的具体大小,因此做了优化,减少布局计算过程。我们展示列表数据时,通常条目是等高(宽)的。因此强烈建议创建 ListView 时提供 itemExtent 参数,选择更优的 widget 来提高我们 APP 的渲染速度。

5. 最后。

没有总结,就这。

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
转载请注明出处: https://daima100.com/13466.html

(0)

相关推荐

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注