内容简介:大家好久不见,又有一个多月没有发文章了,所以今天发一篇来刷刷存在感。最近 Flutter 非常火,我这一个月也不断的找资料来学习 Flutter。经过一段时间的摸索,我发现现在很多资料都非常”水“。各种 Dart 入门、Flutter 入门、Flutter 资料收集,完全没有任何有趣的东西。我不想去写重复而无聊的文章,所以本篇文章会抛转引玉的探讨一些在学习和开发 Flutter 的过程中遇见的问题和解决方案。天下事有难易乎?为之,则难者亦易已!Q:Flutter 怎么学?
本文首发于微信公众号——世界上有意思的事,搬运转载请注明出处,否则将追究版权责任。交流qq群:859640274
大家好久不见,又有一个多月没有发文章了,所以今天发一篇来刷刷存在感。最近 Flutter 非常火,我这一个月也不断的找资料来学习 Flutter。经过一段时间的摸索,我发现现在很多资料都非常”水“。各种 Dart 入门、Flutter 入门、Flutter 资料收集,完全没有任何有趣的东西。我不想去写重复而无聊的文章,所以本篇文章会抛转引玉的探讨一些在学习和开发 Flutter 的过程中遇见的问题和解决方案。
阅读须知:
- 1.WE——>WsElement、ECWS——>ElementContainerWidgetState、EAL——>ElementActionListener
本文分为以下章节,读者可按需阅读:
- 1.Flutter之问——以 QA 的形式来阐述我对 Flutter 的看法和学习经验。
- 2.移植一个Flutter控件——将仿写抖音的贴纸控件移植到 Flutter 中。
- 3.Flutter探究——聊一聊 Flutter 的原理。
- 4.尾巴
一、Flutter之问
天下事有难易乎?为之,则难者亦易已!
Q:Flutter 怎么学?
A:这是老生常谈的问题了。随便打开一个 Flutter 系列文章,都会为你铺平接下来几周的路。但是几周之后呢?似乎很少文章会接着写下去,**毕竟大脑最喜欢简单的东西(我也不例外),一件事情的难度与受欢迎程度成反比。**所以 Flutter 怎么学?所谓:取乎其上,得乎其中。我只有一句话: 以让 Flutter 成为你最拿手技能为目标去学。
Q:能给一些 Flutter 的学习资料吗?
A:我列举一下我学习 Flutter 过程中用到的资料:
-
1.Dart官网,啃完官方文档,Dart 你就入门了。
-
2.Flutter实战,这本开源书的例子很多,全部敲一遍 flutter 你就入门了。特别是最后的 Flutter 原理分析可以仔细看看。
-
3. Flutter github 仓库 ,现在网络上 Flutter 原理分析的文章真的非常少,所以真想要成为 Flutter 专家,你必须作为开拓者去阅读 Flutter 在各种层级下的源码。
Q:Flutter 会干掉 Native?
A:Flutter 是 Native 的子集。在手机被”革命“之前,但凡业务比较复杂的公司,只会要求 Native 工程师掌握 Flutter。而不会出现抛弃 Native 只做 Flutter 的工程师,因为 Flutter 说一千道一万只是一个 ui 框架。毕竟它自身的复杂度很难支撑起比它还复杂的业务。 以上只是个人观点,有分歧可以在评论区探讨 。
Q:Flutter 哪些地方做的比 Native 好?
A:下面是我总结出来的 Flutter 比 Native 好的地方:
- 1.ios、android 一把抓,还可能带上 web、mac、pc。
- 2.Dart 语言非常现代,比 java 、oc 好上太多。
- 3.新兴框架没有历史包袱。
- 4.热更技术非常诱人。
- 5.入门很简单。
二、移植一个FluTter控件
经常读我的文章的读者应该看过我上一篇文章: 抖音、ins、微信功能大比拼——Story的贴纸文字 ,这篇文章中详细比较了各家 Story 的贴纸文字的功能,然后在 Android 端实现了一个贴纸框架。而这一章我就打算将这个贴纸框架移植到 Flutter,相信最后的还原度会超过你的想象。接下来建议配合源码阅读文章。 注意这一章的大部分内容和上一篇文章中讲解 Android 端实现控件的章节是差不多的。
使用方式:sticker_framework: ^0.0.1
1.架构方式
我们第一节先讲讲文字贴纸控件的架构实现,我会基于下面的 图1 和 github 上的代码进行讲解。建议大家把代码 clone 下来, 当然别忘了给个 star。
我们先来根据图1来讲讲整个控件的架构
- 1.我们先从整体来看:
- 1.我们需要选择一个 StatefulWidget 作为基本的容器。所以图中的 ElementContainerWidgetState 就是一个构造这样的容器的 State,简单概括一下它有这些功能:
- 1.处理各种手势事件,这里的手势包括单指和双指。
- 2.添加和删除一些子 Widget。这里的子 Widget 用于绘制各种元素。
- 3.提供一些 api 让外部能操控元素。
- 4.提供一个 listener,让外部能够监听内部的各种流程。
- 2.有了绘制容器,我们需要向绘制容器里面添加 Widget。而 Widget 在用户操作的过程中需要有各种数据,所以这里我用了 WE 来封装需要展示的 Widget,其内部有下面这些东西:
- 1.各种用户操作过程中需要的数据例如:scale、rotate、x、y等等。
- 2.有一些方法能够通过数据来更新 Widget。
- 3.提供一些 api 让 ECWS 能更新 WE 里面的数据 。
- 3.由 ECWS 和 WE 就能继续继承出各种各样的扩展控件。
- 1.我们需要选择一个 StatefulWidget 作为基本的容器。所以图中的 ElementContainerWidgetState 就是一个构造这样的容器的 State,简单概括一下它有这些功能:
- 2.整体讲完了,我们就可以来仔细的讲讲图中的流程
- 1.先讲横着的箭头: 外部/内部调用 ,外部需要调用 ECWS 来进行对 WE 的增删改查等操作时会进入这个路径,这个路径里可以有下面这些操作:
- 1.addElement:向 ECWS 中添加一个元素。
- 2.deleteElement:从 ECWS 中删除一个元素。
- 3.update:让 WE 根据当前数构建出一个 Widget。
- 4.findElementByPosition:找到传入的坐标下的最顶层的 WE。
- 5.selectElement:选中一个 WE 且将其调到最顶层。
- 6.unSelectElement:取消选中一个 WE。
- 2.再来讲竖着的箭头: 手势事件流 ,这里中间会经历一些内部逻辑我们后面来讲,最终事件流会触发下面的一系列行为:
- 1.单指移动的整个流程:当我们选中了一个 WE 的时候就可以对它进行移动。这里移动可以分为开始、进行中、结束。每个事件都会调用 WE 的对应方法以更新其内部数据。
- 2.双指旋转缩放的整个流程:当我们选中了一个 WE 的时候可以用双指对它进行缩放和旋转。这里可以分为开始、进行中、结束。这里也会调用 WE 的对应方法更新数据。
- 3.选中元素再次点击:当我们选中了一个 WE 的时候,可以对其再次点击。
- 4.点击空白区域:当我们没有点击任意 WE 的时候可以进行一些操作,例如清除当前 WE 的选中状态。这个行为是可以继承的,可以交由子类来覆写。
- 5.子类事件:我们看上面其实感觉触发的事件比较少。所以在 down、move、up 的时候会优先调用三个方法 downSelectTapOtherAction、scrollSelectTapOtherAction、upSelectTapOtherAction。这三个方法可以被子类覆写,如果返回 true 的话表示事件已经消耗了,ECWS 就不会再触发其他事件。 这样一来子类也可以对手势进行扩展,例如按住某个地方单指缩放等等。
- 7.我图中 ECWS 也实现了一个子类 DECWS,这个类简单的加两个手势:
- 1.单指移动缩放:类似抖音的随拍,按住元素的右下角的时候可以用拖动来对元素进行缩放和旋转。
- 2.删除:类似抖音的随拍,点击元素左上角的时候可以直接删除元素。
- 3.图1中有一个特性其实没有画出来因为画不下了, 那就是:ECWS 在1和2中的几乎所有行为都能被外部监听,ElementActionListener 就是负责监听的接口。ECWS 中存有一个 EAL 的 set 集合所以监听器可以添加多个。
- 1.先讲横着的箭头: 外部/内部调用 ,外部需要调用 ECWS 来进行对 WE 的增删改查等操作时会进入这个路径,这个路径里可以有下面这些操作:
2.技术点实现
我在开发整个控件的时候遇到过比较多的技术实现上的难点,所以这一节就选一些来讲讲,让读者在看源码的时候不会特别困惑。
(1).定义数据结构与绘制坐标系
-----代码块1----- ws_element.dart int mZIndex = -1; // 图像的层级 double mMoveX = 0.0; // 初始化后相对 ElementContainerWidget 中心的移动距离 double mMoveY = 0.0; // 初始化后相对 ElementContainerWidget 中心的移动距离 double mOriginWidth; // 初始化时内容的宽度 double mOriginHeight; // 初始化时内容的高度 Rect mEditRect; // 可绘制的区域 double mRotate = 0.0; // 图像顺时针旋转的角度,以 π 为基准 double mScale = 1.0; // 图像缩放的大小 double mAlpha = 1.0; // 图像的透明度 bool mIsSelected = false; // 是否处于选中状态 bool mIsSingeFingerMove = false; // 是否处于单指移动的状态 bool mIsDoubleFingerScaleAndRotate = false; // 是否处于双指旋转缩放的状态 Widget mElementShowingWidget; // 展示内容的 widget Offset mOffset; // ElementContainerWidget 相对屏幕的位移 复制代码
函数未动数据先行,数据结构是一个框架非常核心的东西,定义了一个好的数据结构可以省去很多不必要的代码。所以这一小节我们来根据代码块1定义一下数据结构和 Widget 绘制坐标系
-
1.我们将 WE 所在的 ECWS 作为 WE 中 view 的可绘制区域,代码块1中的 mEditRect 就是这个区域代表的矩形。所以 mEditRect 一般为**[0, 0, ECWS.getWidth, ECWS.getHeight] ,mEditRect 的单位为 px**。
-
2.我们定义的坐标系原点在 mEditRect 的中心点,也就是 ECWS 的中心点。mMoveX、mMoveY 分别表示 view 距离坐标系原点的距离。因为它们俩默认为 0,所以一般 view 被添加到 ECWS 中的时候默认位置就在 ECWS 的中心。这两个参数的单位为 px 。
-
3.我们的坐标系具有 z 轴,mZIndex 就是 z 轴的坐标,z 轴表示 view 的层叠关系,mZIndex 为 0 时表示 view 在 ECWS 的顶层。mZindex 默认为 -1,表示 view 没有被添加到 ECWS 中。mZIndex 是 整数 。
-
4.我们定义 mRotate 为正时 view 顺时针转动,mRotate 的区间为[-360,360]。
5.我们定义 view 没有缩放的时候 mScale 为 1,mScale 为 2 的时候表示 view 放大 2 倍,以此类推。
-
6.mOriginWidth 和 mOriginHeight 为 view 的初始大小,单位是 px 。
-
7.mAlpha 为 view 的透明度,默认为 1 且小于等于1。
-
8.剩下的参数就不用解释了,代码里面都有注释。
(2).WE是如何刷新元素的
-----代码块2----- ws_element.dart add() { mElementShowingWidget = initWidget(); } Widget initWidget(); Widget buildTransform() { Matrix4 matrix4 = Matrix4.translationValues(mMoveX, mMoveY, 0); matrix4.rotateZ(mRotate); matrix4.scale(mScale, mScale, 1); return Transform( alignment: Alignment.center, transform: matrix4, child: Opacity( opacity: mAlpha, child: mElementShowingWidget, ), ); } 复制代码
- 1.刷新元素的核心代码就是代码块2:
- 1.首先在 ECWS 添加一个 WE 的时候,WE 的子类中可以通过实现 initWidget() 来初始化自己需要的元素内容
- 2.然后每次数据更新时,我们会通过 buildTransform() 构建一个 Widget 给外部使用。
- 3.而 buildTransfrom 内部则是通过 Matrix4 和 Transform 来实现移动旋转缩放,通过 Opacity 来进行 Alpha 变换。
(3).ECWS如何构建整个容器
-----代码块2----- element_container_widget.dart @override Widget build(BuildContext context) { RawGestureDetector gestureDetectorTwo = GestureDetector( child: GestureDetector( child: Stack( alignment: AlignmentDirectional.center, key: globalKey, children: mElementList.map((e) { return e.buildTransform(); }) .toList() .reversed .toList() ), onPanUpdate: onMove, behavior: HitTestBehavior.opaque, ), ).build(context); gestureDetectorTwo.gestures[RotateScaleGestureRecognizer] = GestureRecognizerFactoryWithHandlers<RotateScaleGestureRecognizer>( () => RotateScaleGestureRecognizer(debugOwner: this), (RotateScaleGestureRecognizer instance) { instance ..onStart = onDoubleFingerScaleAndRotateStart ..onUpdate = onDoubleFingerScaleAndRotateProcess ..onEnd = onDoubleFingerScaleAndRotateEnd; }, ); return Listener( child: ConstrainedBox( constraints: BoxConstraints( minHeight: double.infinity, minWidth: double.infinity, ), child: gestureDetectorTwo, ), behavior: HitTestBehavior.opaque, onPointerDown: onDown, onPointerUp: onUp, ); } 复制代码
- 1.我们都知道 State 中需要在 build() 中返回一个 Widget 给 StatefulWidget。
- 2.为了装下多个有层叠关系的元素,我们使用 Stack 作为元素的容器。
- 3.Stack 外面包装了 GestureDetector 来处理 move 事件。
- 4.GestureDetector 外部包装了我自定义的 RotateScaleGestureRecognizer 来处理双指旋转缩放事件。
- 5.最外层则是用 Listener 来监听手指 down 和 up 事件。
- 6.上面这样的设计的原因我会在后面深入 Flutter 的时候讲解。
3.源码流程解析
这一节我主要会对项目中的测试 demo 进行源码流程分析,让读者对控件整体的运行方式有个简单的了解。这一节主要是讲解源码,所以读者一定要去 clone 源码,跟随文章的脚步前进。
(1).添加元素
- 1.简单的初始化动作我就不赘述了,我们从 main.dart 的 add 按钮开始。点击后先会创建一个 StickerElement 这个是我测试用的元素,里面代码很简单也不说了。
- 2. addSelectAndUpdateElement 是一个组合方法,里面调用了 addElement 、 selectElement 、 update ,也就是添加元素,选中元素,更新元素。我们一个个来分析::
- 1. addElement :这个方法里主要做了下面这些事情:
- 1.进行数据检查,如果被添加的 WE 为空或者该 WE 已经在 ECWS 中,那么添加失败。
- 2.在 ECWS 中我维持了一个 WE 的 List,所有的 WE 都存于其中,每次 add 的时候 WE 都会被添加到 list 的最前面 ,其他 WE 的 mZIndex 也会顺势更新。
- 3.调用 WE.add 方法,里面使用 initWidget 初始化了 mElementShowingView,前面我们说过了 initWidget 的逻辑由子类定义。
- 4.调用监听器的对应方法,且调用自动取消选中的方法( ECWS 可以被外部决定是否自动取消选中 )。
- 2. selectElement :WE 被 add 了之后,我们这里直接将其选中,代码里面主要做了下面这些事情:
- 1.进行数据检查,如果需要选中的 WE 没有被添加到 ECWS 中则选中失败。
- 2.将需要选中的 WE 从 list 中移除然后添加到 list 的顶部,然后顺便更新其他 WE 的 mZIndex。
- 3.调用 WE 的 select 方法,里面主要就是更新要选中的 WE 的数据。
- 4.调用监听器对应的方法。
- 3. update :前面都做好了,就需要将 WE 调整到其应该的状态,这里我想大家都猜到了就是调用 setState 然后其会触发我们在第二节中说的 build 方法,然后调用每个 WE 的 buildTransform 返回数据被更新后的 Widget。
- 1. addElement :这个方法里主要做了下面这些事情:
(2).元素单指手势
元素手势不像添加元素那样需要外部调用,元素手势是通过事件分发触发的,我们这里不讲 Flutter 的事件分发机制,只讲我们基于其上的逻辑。
- 1.对于元素单指手势的处理,主要看三个触摸事件:down、move、up。所以我们直接看 ECWS.build 中设置的三个回调方法。
- 1. onDown 里面的逻辑如下:
- 1.通过 findElementByPosition 根据 down 的位置找到当前位置下最顶层的 WE。
- 2.如果当前有选中的 WE 且与当前触摸 WE 是同一个的话,那么先调用 downSelectTapOtherAction ,这个函数可以被子类覆写,默认返回 false。也就是说子类可以优先处理当前事件,如果子类处理了这个事件,那么 return。如果子类不处理,那么将 mMode 标记为 SELECTED_CLICK_OR_MOVE ,表示最终的手势可能是点击元素,也可能是移动元素。具体的行为需要 move 或者 up 的时候才能判定。
- 3.如果当前有选中的 WE 但与当前触摸的 WE 不是同一个的时候也分两种情况:一种情况是触摸的 WE 不存在,此时表示将 mMode 标记为 SINGLE_TAP_BLANK_SCREEN 表示 点击了 ECWS 的空白区域 。另一种情况是触摸的 WE 存在,此时表示重新选中了一个 WE。
- 4.如果当前没有选中的 WE,也会有两种情况:一个是触摸的 WE 也不存在,那么和前面一样表示点击空白区域。否则的话就是选中一个 WE。
- 2. onMove 中会优先将 move 事件交给 scrollSelectTapOtherAction ,该方法也可以被子类覆写,同样默认返回 false,如果子类处理了这个事件,那么就直接 return 了。否则当 mMode 为 SELECTED_CLICK_OR_MOVE(已经选中了 WE 开始移动)、SELECT(没有选中 WE 开始移动)、MOVE(WE 移动过程中) 三种情况中的一种的时候,都可以触发移动手势。具体的逻辑在 singleFingerMove 中:
- 1.先根据 mMode 的状态,调用 singleFingerMoveStart 或 singleFingerMoveProcess 。singleFingerMoveStart 中调用了监听器和 WE 的对应方法,里面基本没什么逻辑。 singleFingerMoveProcess 中也调用了监听和 WE 的对应方法,但是 WE 的对应方法中更新了 mMoveX 和 mMoveY 的数据。
- 2.调用 update 更新 WE 中的 view。将 mMode 设置为 MOVE ,表示处于移动中。
- 3. onUp 方法:
- 1. mMode 为 SELECTED_CLICK_OR_MOVE ,到这里的时候才能确认,用户的行为是 选中了元素之后的点击 ,我们在前面分析过了这里面的事件分发的机制,这里也不赘述了。
- 2. mMode 为 SINGLE_TAP_BLANK_SCREEN ,表示点击 ECWS 的空白处,这里调用的 onClickBlank 也是可以被子类覆写的,可以实现一些自己的逻辑。
- 3. mMode 为 MOVE ,结束调用单指移动结束。
- 1. onDown 里面的逻辑如下:
三、Flutter探究
这一章我会从一个 Android 工程师的角度来研究一下 Flutter,讲一讲我在移植控件时遇见的问题们。
1.Flutter与Android对比
先看看 Flutter 与 Android 写的 App 实际的比较吧
- 1.我在将代码从 Android 移植到 Flutter 上花费了大概 10 个小时。整个控件在 Android 上开始设计到开发完成则是花费了 100 多个小时。所以整个库的移植成本并不算太高。
- 2.看上面 gif 的比较,可以发现流畅度上面并没有区别。我找了几个朋友实际体验了一下,大家都同样没有发现使用起来有差异。
- 3.图3、图4分别是 Flutter 和 Android 的性能图。我们发现的确像很多测评文章里面说到的。Flutter 的内存消耗要比 Native 多。在实验比较的时候我添加了几十个元素。最后两端都稳定在了一个内存数值上面。Flutter 是 256MB 左右,Android 是 128MB 左右。
- 4.在移植代码的过程中,我总结了下面这些写 Java 和 Dart 之间的区别:
- 1.Dart 有非常多的语法糖,代码比起 java 来说有比较多的精简。
- 2.Dart 的传参方式使得写 Flutter 控件的时候更像是在写属性配置表。
2.Flutter原理
以一个 Android 工程师的眼光来看 Flutter
(1).Flutter的事件简单总结
-
1.LIstener 是手势的基础:GestureDetector 是基于 Listener 开发的。
-
2.事件自底向上,事件不可截断
- 1.先定义一下:自底向上表示从子 view 到父 view。自顶向下表示从父 view 到子 view。
- 2.做过 Android 的同学知道 Android 中的事件**是一个自顶向下再自底向上的过程。**在中间的任意一环我们都可以进行拦截,从而让事件不再继续传递。
- 3.Flutter 的事件模型则是: 自底向上,而且目前来看没有任何操作能阻断这个流程。
- 4.也就是说,如果我们使用 Listener 对任意一个 Widget 进行监听,那么我们在事件传递的过程中阻止 Listener 获取事件。
- 5.事件不可截断的特性在开发中最有用的地方就是:如果我们使用 tapUp,tapDown,这类手势想要监听手指的抬起和放下,那么这些手势可能会被其他手势给冲掉。此时我们就能使用 Listener 来通过监听具体的 down 和 up 事件,因为这个是不可截断的。
-
3.开发中我们使用 GestureDetector 封装 Widget,我们定义的一个个手势回调会让 GestureDetector 生成多个 GestureRecognizer 附着在当前的 Widget 上以处理 Widget 接收到的事件。
-
4.每根手指的 down、move、up 都是一个事件流,当 down 事件自底向上确立了一个 Widget 链的时候,附着在链中各个 Widget 上的 GestureRecognizer 们就会去竞争这个事件流的归属。
-
5.一个事件流的胜出 GestureRecognizer 只有一个,胜出后整个事件流都属于这个 GestureRecognizer 。
-
6.GestureRecognizer 的胜出机制,就是 Flutter 在事件不可截断这个 feature 上的补充的灵活性,可以使得某个 Widget 上的手势被截断, 推荐优先使用 Gesture 。
-
7.Gesture 的胜出机制是怎么样的?
- 1.如果一次竞争中只有一个 GestureRecognizer,那么他就直接胜出。
- 2.如果一次竞争中有多个相同的 GestureRecognizer,那么越底层的越胜出。
- 3.如果一次竞争中有不同的 GestureRecognizer:
- 1.GestureRecognizer 中定义了一个超时机制,有些 GestureRecognizer 定义了某个事件进行了一个时间阈值后如果没有其他 GestureRecognizer 申请延长阈值那么本 GestureRecognizer 就直接胜出。例如:TapGestureRecognizer 定义了 down 事件进行了 100 ms 之后,如果没有其他 GestureRecognizer 延长阈值,那么自己就获得事件流。
- 2.而 LongPressGestureRecognizer 定义的时间阈值是 500ms,如果 500ms 后没有其他 GestureRecognizer 申请延长阈值则自己获得事件流。
- 3.那么 TapGestureRecognizer 和 LongPressGestureRecognizer 都在的时候,通过 down 事件的长短来判断谁胜出。
(2).Flutter的绘制逻辑
四、尾巴
啊!感觉这篇文章有点虎头蛇尾的感觉,文章从开始到结束跨了好几周。中间又是加班又是搬家,把我的热血都消磨了。本来多加一些 Flutter 的深入探究的,但是感觉会越写越久,所以先就这样。接下来我会写一系列文章来分析 Flutter 的原理和 Flutter Sdk。所以更多内容敬请期待!ps: 一鼓作气,再而竭,三而衰。真是完美的表现了我写这篇文章的过程,希望读者们不要学我。
连载文章
- 1.从零开始仿写一个抖音app——开始
- 4.从零开始仿写一个抖音App——日志和埋点以及后端初步架构
- 5.从零开始仿写一个抖音App——app架构更新与网络层定制
- 6.从零开始仿写一个抖音App——音视频开篇
- 7.从零开始仿写一个抖音App——基于FFmpeg的极简视频播放器
- 8.从零开始仿写一个抖音App——跨平台视频编辑SDK项目搭建
- 9.从零开始仿写一个抖音App——Android绘制机制以及Surface家族源码全解析
不贩卖焦虑,也不标题党。分享一些这个世界上有意思的事情。题材包括且不限于:科幻、科学、科技、互联网、 程序员 、计算机编程。下面是我的微信公众号: 世界上有意思的事 ,干货多多等你来看。
以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网
猜你喜欢:- 移植 Lua 到鸿蒙:首个移植成功的编程语言
- 移植luaCoco
- zeppelin 安装移植简述
- C 版本 MQTT 移植 Android
- 从Redis到Codis移植实践
- MTCNN移植安卓并检测视频中人脸
本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。