前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
圈层
工具
发布
首页
学习
活动
专区
圈层
工具
MCP广场
社区首页 >专栏 >Flutter 绘制探索 | 绘制中的动画变换

Flutter 绘制探索 | 绘制中的动画变换

作者头像
张风捷特烈
发布于 2023-04-23 08:12:45
发布于 2023-04-23 08:12:45
1.2K00
代码可运行
举报
运行总次数:0
代码可运行
theme: cyanosis
前言:

这篇文章来通过一个有趣的案例,介绍一下 绘制中的动画变换 ,以及如何在当前的变换基础上,叠加变换。如下所示,小车在界面上呈现的任何变动,都是变换矩阵作用的效果: 注: gif 图片为 15fps ,有些卡顿,非实际动画运行效果


1. 图片的绘制

首先看一下如何在 Flutter 中绘制一张资源图片。如下所示,在 assets/images 中有一张小车的图片:

要使用资源,需要在 pubspec.yaml 中配置文件夹的逻辑:

代码语言:javascript
代码运行次数:0
运行
复制
flutter:
  assets:
    - assets/images/

在 Flutter 的 Canvas 绘制中,drawImage 方法可以绘制图片,其中的入参 Image 不是 material包的图片组件,而是 dart:ui 中的 Image 图片数据:

可以通过 Flutter 框架中 decodeImageFromList 方法,通过字节数组获取 ui.Image 对象;其中字节数组可以通过文件读取、资源加载、网络下载等形式获取,比如这里获取本地资源中的字节数据可以使用 rootBundle.load 方法:

代码语言:javascript
代码运行次数:0
运行
复制
//读取 assets 中的图片
Future<ui.Image>? loadImageFromAssets(String path) async {
  ByteData data = await rootBundle.load(path);
  return decodeImageFromList(data.buffer.asUint8List());
}

下面 Playground 类继承自 CustomPainter, 表示它是画板的实现类。画板只需要专注于绘制即可,像图片数据加载这种活,画板不应该操心。所以其中持有 ui.Image 对象,并在构造函数中进行初始化。在 paint 方法中使用图像进行绘制。

绘制的内容包括: 画板区域的边线示意矩形框; 小车图像及橙色边线示意框:

代码语言:javascript
代码运行次数:0
运行
复制
class Playground extends CustomPainter {
  final ui.Image? image;
  
  Playground(this.image);

  @override
  void paint(Canvas canvas, Size size) {
    Paint paint = Paint()..style = PaintingStyle.stroke;
    canvas.drawRect(Offset.zero &amp; size, paint);

    if (image != null) {
      drawCarWithRange(canvas, paint);
    }
  }

  void drawCarWithRange(Canvas canvas, Paint paint) {
    Rect zone = Rect.fromLTRB(0, 0, image!.width.toDouble(), image!.width.toDouble());
    paint.color = Colors.orange;
    canvas.drawRect(zone, paint);

    // 绘制图片
    canvas.drawImage(image!, Offset.zero, paint);
  }

  @override
  bool shouldRepaint(covariant Playground oldDelegate) {
    return oldDelegate.image!=image;
  }
}

2.界面中的组件布局

案例中的布局也很简单:左边是画板区域,右侧是三个控制按钮,分别用于 恢复原位顺时针旋转 90°动画移动

由于控制按钮的布局相对独立,它与界面其他元素的关系只有回调事件。以后可能会增加其他的按钮,或者修改样式,所以这里将其封装为一个 ControlTools 组件来独立维护,并暴露三个回调给外界来监听事件的触发:

代码语言:javascript
代码运行次数:0
运行
复制
import 'package:flutter/material.dart';

class ControlTools extends StatelessWidget {
  final VoidCallback onReset;
  final VoidCallback onRotate;
  final VoidCallback onMove;

  const ControlTools({
    Key? key,
    required this.onReset,
    required this.onRotate,
    required this.onMove,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 24),
      child: Row(
        children: [
          GestureDetector(
            onTap: onReset,
            child: const Icon(Icons.refresh, color: Colors.blue,),
          ),
          const SizedBox(width: 16),
          GestureDetector(
            onTap: onRotate,
            child: const Icon(Icons.rotate_90_degrees_ccw, color: Colors.blue),
          ),
          const SizedBox(width: 16),
          GestureDetector(
            onTap: onMove,
            child: const Icon(Icons.run_circle_outlined, color: Colors.blue),
          )
        ],
      ),
    );
  }
}

这样也能在一定程度上,缓解主布局界面中的代码混乱程度。下面的 RunCar 组件是当前的主界面,在其状态类的 initState 回调中加载图片资源,为 ui.Image 数据赋值和触发更新。Playground 换班可以通过 CustomPaint 组件呈现在界面上,左右通过 Row 组件进行横向布局:

代码语言:javascript
代码运行次数:0
运行
复制
import 'dart:math';
import 'package:flutter/material.dart';
import 'dart:ui' as ui;
import 'package:flutter/services.dart';

class RunCar extends StatefulWidget {
  const RunCar({Key? key}) : super(key: key);

  @override
  State<RunCar> createState() => _RunCarState();
}

class _RunCarState extends State<RunCar> {
  ui.Image? _image;

  @override
  void initState() {
    super.initState();
    _loadImage();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Row(
          mainAxisSize: MainAxisSize.min,
          children: [
            CustomPaint(
              size: const Size(400, 400),
              painter: Playground(_image),
            ),
            ControlTools(
              onReset: _onReset,
              onMove: _onMove,
              onRotate: _onRotate,
            ),
          ],
        ),
      ),
    );
  }

  //读取 assets 中的图片
  Future<ui.Image>? loadImageFromAssets(String path) async {
    ByteData data = await rootBundle.load(path);
    return decodeImageFromList(data.buffer.asUint8List());
  }

  void _loadImage() async {
    _image = await loadImageFromAssets('assets/images/car.png');
    setState(() {});
  }
}

3.如何对绘制区域进行变换操作

下面来看一下,如何对一部分的绘制内容进行变换,对于移动、平移、缩放等简单的变换 Canvas 中提供了相关的方法。但我们现在要做的,需要基于多个变换进行叠加,比如 移动、旋转、移动、移动,如果每个动作都通过 Canvas 的相关方法进行变换处理,需要很多无谓的计算,也会把过程搞得非常复杂。 Canvas 中有一个 transform 方法,可以通过 Matrix4 矩阵进行变换。而矩阵可以通过乘法进行变换的叠加,下面一个小例子说明一下:

代码语言:javascript
代码运行次数:0
运行
复制
---->[playground.dart#绘制方法]----
@override
void paint(Canvas canvas, Size size) {
  Paint paint = Paint()..style = PaintingStyle.stroke;
  canvas.drawRect(Offset.zero &amp; size, paint);
  if (image != null) {
    // 操作矩阵
    Matrix4 m4 = Matrix4.identity();
    Matrix4 moveMatrix = Matrix4.translationValues(100, 0, 0);
    m4.multiply(moveMatrix);
    
    canvas.save();
    canvas.transform(m4.storage);
    drawCarWithRange(canvas, paint);
    canvas.restore();
  }
}

案例中 m4 矩阵是在绘制图片时施加的变换,moveMatrix 表示移动变换的矩阵。m4.multiply(moveMatrix) 矩阵表示在 m4 上叠加 moveMatrix 变换,本质上是两个 4X4 矩阵的乘法。 触发 multiply 方法后会, m4 矩阵的值会被改变。使用它的数据作为 canvas.transform 的参数,会产生移动的变换效果:


下面再来看下旋转变换,默认情况下 Canvas 在进行变换时是以画布左上角为变换中心的。当叠加顺时针 90° 的旋转变换时,效果如下所示:

代码语言:javascript
代码运行次数:0
运行
复制
Matrix4 m4 = Matrix4.identity();
Matrix4 rotate90 = Matrix4.rotationZ(pi/2);
m4.multiply(rotate90);

// 略同...

其实对于旋转而言,很多时候我们期望旋转中心是在被变换者的中心,这就要对变换中心进行处理。关于这方面,之前出过一个视频,感兴趣的可以看一下 : 《Flutter 绘制实践 | 路径篇 · 变换中心》 。这里就不卖关子了,平移变换可以影响变换中心, 为了抵消平移变换带来的后果,在旋转之后,反向平移即可。矩阵的 multiplied 方法本质上使用的是 multiply,只不过 multiplied 会生成新的矩阵,不会改变调用者的数据。 代码如下:

代码语言:javascript
代码运行次数:0
运行
复制
Matrix4 m4 = Matrix4.identity();
Matrix4 moveCenter = Matrix4.translationValues(50, 50, 0);
Matrix4 moveBack = Matrix4.translationValues(-50, -50, 0);

Matrix4 rotate90 = Matrix4.rotationZ(pi/2);
rotate90 = moveCenter.multiplied(rotate90).multiplied(moveBack);
m4.multiply(rotate90); 

这样就可以达到以中心为旋转中心,旋转 90° 的效果:


最后,来看一下多个矩阵的叠加效果。大家可以先想想一想,如果在上面的旋转变换之后,再叠加 moveMatrix 沿 x 轴移动 100 ,会是什么效果?

代码语言:javascript
代码运行次数:0
运行
复制
// 略同...
m4.multiply(rotate90);   // 叠加旋转变换
m4.multiply(moveMatrix); // 叠加移动变换

答案是向下平移了 100 , 这时可能很多人比较疑惑, moveMatrix 不是沿 x 轴平移的吗,怎么会往下跑。其实矩阵的变换,是图形的相对坐标系统的变换,在当前的视角中,坐标系也被旋转了 90°,在当前变换之下,沿 X 轴移动是下方没有任何问题。


这样的话,名称对 m4 叠加一次 rotate90 变换,它就会以图片中心为原点旋转 90°,每次叠加一次 moveMatrix 就会以车头为正方向平移 100。

代码语言:javascript
代码运行次数:0
运行
复制
// 略同...
m4.multiply(rotate90);
m4.multiply(moveMatrix);
m4.multiply(rotate90);
m4.multiply(rotate90);
m4.multiply(rotate90);
m4.multiply(moveMatrix);

4. 控制矩阵变换

到这里,变换操作就介绍完了,我们只要在点击按钮时通过 multiply 叠加对应的矩阵,就可以完成转动和移动的效果。比如可以通过构造函数将 Matrix4 矩阵作为入参,有界面的交互来更新数据和重绘。如下所示,在画板构造时通过可监听对象来提供矩阵数据:

状态类中维护 _matrix 可监听对象,在点击按钮时,修改变换矩阵值即可。比如移动按钮每点击一次,叠加一个变换移动变换。这样就完成了一个简单版的图像旋转、平移的控制效果。

代码语言:javascript
代码运行次数:0
运行
复制
class _RunCarState extends State<RunCar> with SingleTickerProviderStateMixin {

  //...
  ValueNotifier<Matrix4> _matrix = ValueNotifier(Matrix4.identity());
  late Matrix4 rotate90;
  late Matrix4 moveMatrix;

  @override
  void initState() {
    super.initState();
    //...
    _initMatrix();
  }

  void _initMatrix() {
    // 初始化变换矩阵
    Matrix4 moveCenter = Matrix4.translationValues(50, 50, 0);
    Matrix4 moveBack = Matrix4.translationValues(-50, -50, 0);
    rotate90 = Matrix4.rotationZ(pi/2);
    rotate90 = moveCenter.multiplied(rotate90).multiplied(moveBack);
    moveMatrix = Matrix4.translationValues(100, 0, 0);
  }

  @override
  void dispose() {
    _matrix.dispose();
    super.dispose();
  }

  //...
  void _onRotate() {
    _matrix.value = _matrix.value.multiplied(rotate90);
  }

  void _onMove() {
    _matrix.value = _matrix.value.multiplied(moveMatrix);
  }

  void _onReset() {
    _matrix.value = Matrix4.identity();
  }
}

5. 矩阵补间动画

上面是直接叠加矩阵,点一下动一下,接下来看一下如何为矩阵变换添加动画效果。也就是说在一段时间内会不断对矩阵数据进行更新,从起始矩阵到结束矩阵,在界面上就会呈现动画效果。需要获取动画的驱动力,最简单的方式是让状态类混入 SingleTickerProviderStateMixin,让状态类拥有创建动画控制器的能力:


下面要让动画运动过程中,每帧叠加的矩阵进行动画过渡。矩阵的补间计算可以通过 Matrix4Tween 指定起止矩阵进行计算,下面定义了两个 Matrix4Tween 分别用于处理移动和旋转矩阵的补间:

代码语言:javascript
代码运行次数:0
运行
复制
late Matrix4Tween moveTween;
late Matrix4Tween rotateTween;

void _initTween() {
  rotateTween = Matrix4Tween(begin: Matrix4.rotationZ(0), end: Matrix4.rotationZ(pi/2));
  moveTween = Matrix4Tween(begin: Matrix4.translationValues(0, 0, 0), end: Matrix4.translationValues(100, 0, 0));
}

在移动方法中,监听动画帧的变化,叠加对应的矩阵值即可,如下所示:

代码语言:javascript
代码运行次数:0
运行
复制
void _onMove() {
  Matrix4 start = _matrix.value.clone();
  Animation<Matrix4> m4Anima = moveTween.animate(_controller);
  m4Anima.addListener(() => _matrix.value = start.multiplied(m4Anima.value));
  _controller.forward(from: 0);
}

旋转也是同理:这样就实现了一开始的效果:

代码语言:javascript
代码运行次数:0
运行
复制
final Matrix4 moveCenter = Matrix4.translationValues(50, 50, 0);
final Matrix4 moveBack = Matrix4.translationValues(-50, -50, 0);
void _onRotate() {
  Matrix4 start  = _matrix.value.clone();
  Animation<Matrix4> m4Tween = rotateTween.animate(_controller);
  m4Tween.addListener(() {
    Matrix4 rotate = moveCenter.multiplied(m4Tween.value).multiplied(moveBack);
    _matrix.value = start.multiplied(rotate);
  });
  _controller.forward(from: 0);
}

到这里,关于绘制中的矩阵变换就介绍的差不多了,也知道了如何对矩阵变换进行动画处理,希望可以对你有所帮助。那本文就到这里,谢谢观看 ~

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2023-04-05,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
暂无评论
推荐阅读
【Flutter 绘制技巧】Path 路径变换
本文来探讨一下路径的变换,我们知道 Canvas 本身也支持变换,那 Path 的变换有什么必要性吗?和 Canvas 变换又有什么区别呢?如何在一次变换中叠加多种变换效果,如何修改变换中心?这些都是绘制的基本技能。本文将作为 《Flutter 绘制指南 - 妙笔生花》的补充内容,被同步到小册中。本文源码见 【idraw/extra_03_path】
张风捷特烈
2022/09/20
1.4K0
【Flutter 绘制技巧】Path 路径变换
Flutter 绘制探索 | 来一起画箭头吧
—\ntheme: cyanosis\n—\n##### 0. 前言\n\n可能有人会觉得,画箭头有什么好说的,不就一根线加两个头吗?其实箭头的绘制还是比较复杂的,其中也蕴含着很多绘制的小技巧。箭头本身有着很强的 示意功能 ,通常用于指示、标注、连接。各种不同的箭头端,再加上线型的不同,可以组合成一些固定连接语法,比如 UML 中的类图。\n\n
张风捷特烈
2022/09/08
8070
Flutter 绘制探索 | 来一起画箭头吧
【Flutter 专题】44 图解矩阵变换 Transform 类 (一)
和尚在学习矩阵变换时需要用到 Transform 类,可以实现子 Widget 的 scale 缩放 / translate 平移 / rotate 旋转 / skew 斜切 等效果,对应于 Canvas 绘制过程中的矩阵变换等;和尚今对此进行初步整理;
阿策小和尚
2019/08/12
2.5K0
【Flutter 专题】44 图解矩阵变换 Transform 类 (一)
【Flutter 专题】45 图解矩阵变换 Transform 类 (二)
和尚刚学习了 Transform 类,其核心部分在于矩阵变换,而矩阵变换是由 Matrix4 处理的,且无论是如何的平移旋转等操作,根本上还是一个四阶矩阵操作的;接下来和尚学习一下 Matrix4 的基本用法;
阿策小和尚
2019/08/12
1.6K0
【Flutter 专题】45 图解矩阵变换 Transform 类 (二)
flutter系列之:flutter中的变形金刚Transform
虽然我们在开发APP的过程中是以功能为主,但是有时候为了美观或者其他的特殊的需求,需要对组件进行一些变换。在Flutter中这种变换就叫做Transform。
程序那些事
2022/12/05
3600
Flutter 像素编辑器#05 | 缩放与平移
之前已经实现了像素编辑器的基本功能,但是目前绘制的区域是固定大小。这样在行列数非常大时,就会导致绘制格非常小,不便于绘制。所以希望布局区域可以向 Photoshop 一样,能够缩放和平移,让用户更自由地绘制。
张风捷特烈
2024/06/25
2250
Flutter 像素编辑器#05 | 缩放与平移
Flutter 绘制探索 | 箭头端点的设计
上一篇 《Flutter 绘制探索 | 来一起画箭头吧》 ,实现了一个可以自由拓展的箭头绘制小体系。线和箭头的旋转已经封装好了,只需要在矩形端点矩形域中提供路径即可。本文我们就来对端点的箭头路径进行拓展,丰富箭头的样式,同时也更方便使用者调用。 毕竟用别人现成的要比自己绘制简单地多,也不是所有人都有绘制的能力。这个箭头小系列就是为了打造一个小巧、便捷的箭头绘制库。所以丰富箭头样式是其中主要的一环。
张风捷特烈
2022/09/20
7780
Flutter 绘制探索 | 箭头端点的设计
Flutter 知识集锦 | 基于 Flow 实现滑动显隐层
最近要实现一个小需求,涵盖了很多知识点,比如手势、动画、布局等。挺有意思的,写出来和大家分享一下。如下所示,分为上下两层;当左右滑时,上层会随偏移量而平移,从而让上层产生滑动手势显隐的效果:
张风捷特烈
2023/03/16
7420
Flutter 知识集锦 | 基于 Flow 实现滑动显隐层
【Flutter高级玩法- Flow 】我的位置我做主
零、前言 Flow布局是一个超级强大的布局,但应该很少有人用,因为入手的门槛还是有的 Flow的属性很简单,只有FlowDelegate类型的delegate和组件列表children, 可能很多人看到delegate就挥挥手:臣妾做不到,今天就来掰扯一下这个FlowDelegate. class Flow extends MultiChildRenderObjectWidget { Flow({ Key key, @required this.delegate, Li
张风捷特烈
2020/10/16
6540
【Flutter高级玩法- Flow 】我的位置我做主
【Flutter高级玩法- Flow 】我的位置我做主
零、前言 Flow布局是一个超级强大的布局,但应该很少有人用,因为入手的门槛还是有的 Flow的属性很简单,只有FlowDelegate类型的delegate和组件列表children, 可能很多人看到delegate就挥挥手:臣妾做不到,今天就来掰扯一下这个FlowDelegate. class Flow extends MultiChildRenderObjectWidget { Flow({ Key key, @required this.delegate, Li
张风捷特烈
2020/04/30
1.7K0
【Flutter高级玩法- Flow 】我的位置我做主
【Flutter 绘制番外】svg 文件与绘制 (中)
上一篇《【Flutter 绘制番外】svg 文件与绘制 (上)》中,我们对 H、V、L 三个 svg 指令做了介绍,并通过正则表达式进行解析,生成 Flutter 绘制中的 Path 路径。 本篇中将会介绍两个指令 C 和 Q ,它们分别代表 三次贝塞尔曲线(cubic) 和 二次贝塞尔曲线(quadratic) 。对这两个指令进行解析后,就可以让掘金的 svg 图标完美显示了:
张风捷特烈
2022/03/25
1.2K0
【Flutter 绘制番外】svg 文件与绘制 (中)
flutter系列之:flutter中常用的container layout详解
在上一篇文章中,我们列举了flutter中的所有layout类,并且详细介绍了两个非常常用的layout:Row和Column。
程序那些事
2022/09/08
3480
Flutter 绘制集录 | 第四画 - 风车
最近源码看得比较多,本文来画点东西调节下心情,本绘制已收录于 FlutterUnit 的绘制集录,本文源码可参见【windmill.dart】 。绘制内容非常简单,如下所示,两个样式的小风车:通过这两个小例子,可以学到:
张风捷特烈
2022/11/18
6100
Flutter 绘制集录 | 第四画 - 风车
[-Flutter 自组篇-] 蛛网图+绘制+动画实践
在Android的时候自定义过蛛网图,花了半天时间。复刻到Flutter只用了不到20分钟 不得不说Flutter中的Canvas对安卓玩家还是非常友好的,越来越觉得Flutter非常有趣。 在视
张风捷特烈
2020/04/30
1.5K0
[-Flutter 自组篇-] 蛛网图+绘制+动画实践
Flutter动画之自定义动画组件-FlutterLayout
前言: 本文将自定义一个FlutterWidget的动画组件,Flutter有颤动的意思 在此之前会讲一下AnimatedWidget与AnimatedBuilder是什么,如何使用 所以本文是一篇挺重要的文章,不仅是内容,还有思想和灵魂。 今天也悟到了一段话分享给大家: 当你遇到一群共事之人,开始难免会觉得某某人高冷而帅气,某某人美丽而大方,某某人能力超级强 作为普通人的你也许很想和他们结交但又很难进入他们的世界,于是你在角落静静凝望,细心观察 随着时间的流逝,也许偶尔的交谈,你会发现他们
张风捷特烈
2020/04/30
2K0
Flutter动画之自定义动画组件-FlutterLayout
flutter路径的用法(下)
本节目标: [1]. 了解路径的 [封闭] [重置] [偏移] 操作。 [2]. 了解路径的 [矩形边距] 和 [检测点是否在路径中]。 [3]. 了解路径的 [路径变换] 和 [路径联合]。 [4]. 了解路径测量的用法和作用。 ---- 一、路径操作 路径的操作是路径使用的重要一环,很多路径的特效和复杂路径的拼合都会使用它们。 ---- 1.close、reset、shift path#close :用于将路径尾点和起点,进行路径封闭。 path#reset :用于将路径进行重置,清除路径内容。
用户1974410
2022/09/20
1K0
flutter路径的用法(下)
Flutter 动画之 Animation
1.前言 1.1:Flutter动画中: 首先要看的是Flutter中动画的几个类之间的关系: 主角当然是我们的Animation类了,它可以借助Animatable进行强化 Animata
张风捷特烈
2020/04/30
2.1K0
Flutter 动画之 Animation
flutter绘制的基础
我们去绘画的时候我们会想在哪画,画什么,怎么画。相对于画家这种创造性的职业,画什么是他们最难的。到我们我们程序员这里就不必考虑的太多。在哪画都是固定的,画什么需求都定好了,怎么画这才是考验我们程序员的。
用户1974410
2022/09/20
9950
flutter绘制的基础
Flutter使用Canvas实现小白兔的绘制
前面两篇文章讲解了在 Flutter 中使用 Canvas 分别实现了精美表盘和微信红包效果,本篇将继续带领你使用 Canvas 实现简笔的小白兔效果,使用的核心技术为二次贝塞尔曲线和三次贝塞尔曲线的运用。
loongwind
2022/09/27
1K0
Flutter使用Canvas实现小白兔的绘制
Flutter 组件 | 手牵手,一起走 CompositedTransformFollower 与 CompositedTransformTarget
其实之前这两个组件我一直都不知道它们是干嘛用的,直到有一天我在看 Slider 的源码时发现了他俩。我们都知道,当 Slider 组件设置了 label 和 divisions 时,在拖动的过程中会弹出 Overlay 提示框。
张风捷特烈
2022/03/08
1.8K1
Flutter 组件 | 手牵手,一起走 CompositedTransformFollower 与 CompositedTransformTarget
相关推荐
【Flutter 绘制技巧】Path 路径变换
更多 >
目录
  • 前言:
  • 1. 图片的绘制
  • 2.界面中的组件布局
  • 3.如何对绘制区域进行变换操作
  • 4. 控制矩阵变换
  • 5. 矩阵补间动画
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档
本文部分代码块支持一键运行,欢迎体验
本文部分代码块支持一键运行,欢迎体验