【解读】声网 Agora音视频技术与AI方案解决

声网 Agora

原创声明,cv调包侠

							50+新增数据中心

						  300%全网带宽容量上涨

                        中国区支持百万大频道动态扩展能力

						海外大频道扩容时间缩短50%

							日分钟数超过6亿

					支持 5G 网络下高清、大码率视频传输

				移动端超分、感知视频编码、AI 降噪等新技术落地

					岁月不待人,2019年,已经过去。

							我们和你一样,

					在通往实时互联网的路上狂奔,

							创造从未停歇。	

我曾被这所感染,声网是我一直就听说的,在我和身边的朋友用了许久这个产品后,写下这文。

2019年上线的2.4.1版本, 该版本进一步完善和优化了美颜、屏幕共享等功能,提升了推流服务稳定性,移动端增加了多个在线音效文件播放的支持,部分场景的音质得到了进一步优化。该版本还提供了媒体附属信息的支持,方便开发者在音视频直播等场景中增加互动信息。新版本新增了网络连接类型变化回调、本地视频状态变化回调、推流状态变化回调、远端音频首帧解码回调以及获取本地和远端播放背景音频音量接口。完善网络连接状态改变原因回调、SDK 和边缘服务器之间上下行丢包率的统计、本地和远端视频解码帧率和渲染帧率相关等接口**。同时,全平台提供统一 C++接口**,Windows 新增64位开发包,并面向 Windows 和 Mac 平台新增了纯语音开发包。

媒体附属信息

在一些互动直播场景下,主播有可能会需要发送一些媒体附属信息给观众,比如在线答题中的题目,电商直播中的商品链接、优惠券,或者是在线教育中的题目等。这些都能以 meta-data 的形式添加到视频帧中,与视频数据一起同步下发给观众。

屏幕共享新增参数

2.4 版中,对屏幕共享功能进行大幅的优化。在 2.4.1 中,为方便用户选择屏幕共享时是否采集鼠标,该版本在AgoraScreenCaptureParameters (ScreenCaptureParameters)类中新增 captureMouseCursor参数。如果不进行设置,该参数默认采集鼠标,即ScreenCaptureParameters增加了captureMouseCursor成员,缺省值为true。

到这里,我想起了我去年做的UI自动化中接触的,比如常用的鼠标键盘监听与控制。

美颜优化

2.4.1 版中,对美颜的功能与性能进行了大幅优化。

我们为美颜选项 BeautyOptions 类提供了默认参数,提高了易用性。同时,该版本优化了美颜算法的性能,对低端机型更为友好。根据声网实验室报告显示,优化后的算法下,GPU 消耗、CPU 消耗和功耗均有不同程度的下降。

这个功能,我有体验过,因为我自己做过基于深度学习与计算机视觉算法的一些美颜算法,比如GAN等算法,大眼算法,用过这次优化后的算法,我觉得我是自愧不如,这边做得很好,值得我阅读源码学习。

语音和视频的延时问题

我认为,延时问题是及其重要且紧急的任务,不同配置的设备,cpu,gpu性能,网速,以及添加美颜等需要高gpu运算的算法加持,需要做大量的消融对比,测试工作,来寻找一种计算与运行时延的trade off,在我使用过程中,我发现,确实不错,延时不能人感。我以前做刚接触推拉流的时候,以及云上传输的时候,时延问题,一直困扰着我,我后来阅读了许多资料,以及声网github的开发者中心,我发现了新大陆。。

Flutter之声网Agora实现音频

我和我的朋友体验了这个,特此写下体验记录,他也用了半年了。

扫描二维码关注公众号,回复: 12414809 查看本文章

用Flutter来实现,因此声网插件应该在https://pub.dev/packages/上

整个demo例子结构很简单,主要是四个Dart文件:分别是视频语音对象,首页,语音页,视频页。

1.首页

首页布局很简单,就两个按钮,分别是语音通话和视频通话,先上草图:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-3aXM8EXs-1602401802445)(D:\CSDN\pic\天池\pic\demo.jpg)]

根布局是Center,孩子是RowRow里分别是左右排列的RaisedButton按钮,代码具体如下:

@override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Row(
          crossAxisAlignment: CrossAxisAlignment.start,
          mainAxisAlignment: MainAxisAlignment.spaceEvenly,//主轴空白区域均分
          children: <Widget>[
            //左边的按钮
            RaisedButton(
              padding: EdgeInsets.all(0),
              //点击事件
              onPressed: () {
                //去往语音页面
                onAudio();
              },
              child: Container(
                height: 120,
                width: 120,
                //装饰
                decoration: BoxDecoration(

                    //渐变色
                    gradient: const LinearGradient(
                      colors: [Colors.blueAccent, Colors.lightBlueAccent],
                    ),
                    //圆角12度
                    borderRadius: BorderRadius.circular(12.0)),
                child: Text(
                  "语音通话",
                  style: TextStyle(color: Colors.white, fontSize: 18.0),
                ),
                //文字居中
                alignment: Alignment.center,
              ),
              shape: new RoundedRectangleBorder(
                borderRadius: BorderRadius.circular(12.0),
              ),
            ),
            //右边的按钮
            RaisedButton(
              padding: EdgeInsets.all(0),
              onPressed: () {
                //去往视频页面
                onVideo();
              },
              child: Container(
                height: 120,
                width: 120,
                //装饰--->渐变
                decoration: BoxDecoration(
                    gradient: const LinearGradient(
                      colors: [Colors.blueAccent, Colors.lightBlueAccent],
                    ),
                    //圆角12度
                    borderRadius: BorderRadius.circular(12.0)),
                child: Text(
                  "视频通话",
                  style: TextStyle(color: Colors.white, fontSize: 18.0),
                ),
                //文字居中
                alignment: Alignment.center,
              ),
              shape: new RoundedRectangleBorder(
                borderRadius: BorderRadius.circular(12.0),
              ),
            ),
          ],
        ),
      ),
    );
  }

效果如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-WrnlTRxe-1602401802448)(D:\CSDN\pic\天池\pic\demo1.jpg)]

下面实现点击事件,逻辑很简单,首先是要授予权限(权限用simple_permissions这个库),权限授予之后再进入相应的页面:

语音点击事件onAudio()

  onAudio() async {
    
    
    SimplePermissions.requestPermission(Permission.RecordAudio)
        .then((status_first) {
    
    
      if (status_first == PermissionStatus.denied) {
    
    
        //如果拒绝
        Toast.show("此功能需要授予录音权限", context,
            duration: Toast.LENGTH_SHORT, gravity: Toast.CENTER);
      } else if (status_first == PermissionStatus.authorized) {
    
    
        //如果授权同意 跳转到语音页面
        Navigator.push(
          context,
          MaterialPageRoute(
            builder: (context) => new AudioCallPage(
                  //频道写死,为了方便体验
                  channelName: "122343",
                ),
          ),
        );
      }
    });
  }

语音只授予录音权限即可。

  • 视频通点击事件onVideo()
    视频需要授予的权限多了相机权限而儿:
   onVideo() async {
    
    
    SimplePermissions.requestPermission(Permission.Camera).then((status_first) {
    
    
      if (status_first == PermissionStatus.denied) {
    
    
        //如果拒绝
        Toast.show("此功能需要授予相机权限", context,
            duration: Toast.LENGTH_SHORT, gravity: Toast.CENTER);
      } else if (status_first == PermissionStatus.authorized) {
    
    
        //如果同意
        SimplePermissions.requestPermission(Permission.RecordAudio)
            .then((status_second) {
    
    
          if (status_second == PermissionStatus.denied) {
    
    
            //如果拒绝
            Toast.show("此功能需要授予录音权限", context,
                duration: Toast.LENGTH_SHORT, gravity: Toast.CENTER);
          } else if (status_second == PermissionStatus.authorized) {
    
    
            //如果授权同意
            Navigator.push(
              context,
              MaterialPageRoute(
                builder: (context) => new VideoCallPage(
                      //视频房间频道号写死,为了方便体验
                      channelName: "122343",
                    ),
              ),
            );
          }
        });
      }
    });
  }

这样首页算完成了。

2.语音页面(AudioCallPage)

这里我只做了一对一语音通话的界面效果,也可以实现多人通话,只是把界面样式改成自己喜欢的样式即可。

2.1.样式

一对一通话的界面类似微信语音通话界面一样,屏幕中间是对方头像(这里我只显示对方用户ID),底部是菜单栏:是否静音,挂断,是否外放,草图如下:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-YLM0iu5n-1602401802451)(D:\CSDN\pic\天池\pic\demo2.jpg)]

主要用Stack层叠控件+Positioned来定位:

  @override
  Widget build(BuildContext context) {
    return new Scaffold(
      appBar: new AppBar(
        title: Text(widget.channelName),
      ),
      //背景黑色
      backgroundColor: Colors.black,
      body: new Center(
        child: Stack(
          children: <Widget>[_viewAudio(), _bottomToolBar()],
        ),
      ),
    );
  }

2.2.逻辑

实现语音主要五个步骤,分别是:

  • 初始化引擎
  • 启用音频模块
  • 创建房间
  • 设置事件监听(成功加入房间,是否有用户加入,用户是否离开,用户是否掉线)
  • 布局实现
  • 退出语音(根据需要销毁引擎,释放资源)
2.2.1.初始化引擎

初始化引擎只有一句代码:

    //初始化引擎
    AgoraRtcEngine.create(agore_appId);

2.2.2.启用音频模块

启用音频模块:

    //设置视频为可用 启用音频模块
    AgoraRtcEngine.enableAudio();

看官方文档介绍:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-UMEfEFAT-1602401802455)(D:\CSDN\pic\天池\pic\demo3.jpg)]

2.2.3.加入房间

当初始化完引擎和启用音频模块后,下面进行创建房间:

  //创建渲染视图
  void _createRendererView(int uid) {
    //增加音频会话对象 为了音频布局需要(通过uid和容器信息)
    //加入频道 第一个参数是 token 第二个是频道id 第三个参数 频道信息 一般为空 第四个 用户id
    setState(() {
      AgoraRtcEngine.joinChannel(null, widget.channelName, null, uid);
    });

    VideoUserSession videoUserSession = VideoUserSession(uid);
    _userSessions.add(videoUserSession);
    print("集合大小"+_userSessions.length.toString());
  }

主要看AgoraRtcEngine.joinChannel(null, widget.channelName, null, uid);这个方法:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-RaEF7fXI-1602401802459)(D:\CSDN\pic\天池\pic\demo4.jpg)]

第一个参数是服务器生成的token,第二个参数是声音的频道号,第三个参数是频道的信息,第四个参数是用户的uid,我这边传0,sdk会自动分配。另外注意我这边用VideoUserSession类来管理用户信息,通过集合List来存放当前在房间的人数,目的就是为了布局方便。

2.2.4.设置事件的监听

当如果有用户新加入进来,或者用户离开又或者是掉线,我们能不能知道呢?答案是肯定的:

  //设置事件监听
  void setAgoreEventListener() {
    //成功加入房间
    AgoraRtcEngine.onJoinChannelSuccess =
        (String channel, int uid, int elapsed) {
      print("成功加入房间,频道号:${channel}+uid+${uid}");
    };

    //监听是否有新用户加入
    AgoraRtcEngine.onUserJoined = (int uid, int elapsed) {
      print("新用户所加入的id为:$uid");

      setState(() {
        //更新UI布局
        _createRendererView(uid);
        self_uid = uid;
      });
    };

    //监听用户是否离开这个房间
    AgoraRtcEngine.onUserOffline = (int uid, int reason) {
      print("用户离开的id为:$uid");
      setState(() {
        //移除用户 更新UI布局
        _removeRenderView(uid);
      });
    };

    //监听用户是否离开这个频道
    AgoraRtcEngine.onLeaveChannel = () {
      print("用户离开");
    };
  }

2.2.5.布局实现

下面简单实现屏幕中间的UI实现,我这边只做了一对一通话,也就是中间只显示对方的用户id,如果多人通话,也可以根据List的数量依次显示。

  //音频布局视图布局
  Widget _viewAudio() {
    
    
    //先获取音频人数
    List<int> views = _getRenderViews();
    switch (views.length) {
    
    
      //只有一个用户(即自己)
      case 1:
        return Center(
          child: Container(
            child: Text("用户1"),
          ),
        );
      //两个用户
      case 2:
        return Positioned(//在中间显示对方id
          top: 180,
          left: 30,
          right: 30,
          child: Container(
            height: 260,
            child: Column(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              crossAxisAlignment: CrossAxisAlignment.center,
              children: <Widget>[
                ClipRRect(
                  borderRadius: BorderRadius.circular(10),
                  child: Container(
                    alignment: Alignment.center,
                    width: 140,
                    height: 140,
                    color: Colors.red,
                    child: Text("对方用户uid:\n${self_uid}",
                      textAlign: TextAlign.center,

                      style: TextStyle(color: Colors.white),
                    ),
                  ),
                ),
              ],
            ),
          ),
        );

      default:
    }
    return new Container();
  }

上面主要是根据List集合自己控制语音通过页面。

2.2.6.退出语音

如果用户退出本界面或者挂断,必须调用AgoraRtcEngine.leaveChannel();

  //本页面即将销毁
  @override
    void dispose() {
    
    
    //把集合清掉
    _userSessions.clear();
    AgoraRtcEngine.leaveChannel();
    //sdk资源释放
    AgoraRtcEngine.destroy();
    super.dispose();
  }

当有用户离开了这个房间后,会回调AgoraRtcEngine.onUserOffline这个方法,文档也有说明:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-5dB8iveD-1602401802461)(D:\CSDN\pic\天池\pic\demo5.jpg)]

文档清晰说明当用户主动离开或者掉线都会回调这个方法,我通过这个方法来实现当用户退出房间后(移除用户会话对象)UI更新效果:

  //移除对应的用户界面 并且移除用户会话对象
  void _removeRenderView(int uid) {
    
    
    //先从会话对象根据uid来清除
    VideoUserSession videoUserSession = _getVideoUidSession(uid);

    if (videoUserSession != null) {
    
    
      _userSessions.remove(videoUserSession);
    }
  }

2.2.7.是否静音

是否静音是通过AgoraRtcEngine.muteLocalAudioStream(muted);方法来实现:

  //开关本地音频发送
  void _isMute() {
    
    
    setState(() {
    
    
      muted = !muted;
    });
    // true:麦克风静音 false:取消静音(默认)
    AgoraRtcEngine.muteLocalAudioStream(muted);
  }

2.2.8.是否开扬声器
  //是否开启扬声器
  void _isSpeakPhone() {
    
    
    setState(() {
    
    
      speakPhone = !speakPhone;
    });
    AgoraRtcEngine.setEnableSpeakerphone(speakPhone);
  }

2.3.最终效果

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-kbkfska4-1602401802464)(D:\CSDN\pic\天池\pic\demo6.jpg)]

因为是gif,所以听不见声音,上面还有两个小问题要完善的:

  • 一对一通话应该是双方连接才能进入通话界面
  • 当一方退出后,另一方也应该退出

3.视频页面(VideoCallPage)

这里视频支持多人视频,工具栏也和语音一样,也是在底部,当和一对一对方视频通话时,屏幕分为两部分,上面是自己,下面是对方的视频,其他逻辑和语音基本一致,实现视频主要有四个步骤:

  • 初始化引擎
  • 启用视频模块
  • 创建视频渲染视图
  • 设置本地视图
  • 开启视频预览
  • 加入频道
  • 设置事件监听

3.1.启用视频

启用视频模块主要也是一句代码AgoraRtcEngine.enableVideo();,看文档说明:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-l3aBg0iP-1602401802465)(D:\CSDN\pic\天池\pic\demo7.jpg)]

主要意思是可以在加入频道之前或通话期间调用此方法。

3.2.创建视频渲染视图

创建视频播放插件:

  //创建渲染视图
  void _createDrawView(int uid,Function(int viewId) successCreate){
    
    
    //该方法创建视频渲染视图 并且添加新的视频会话对象,这个渲染视图能用在本地/远端流 这里需要更新
    //Agora SDK 在 App 提供的 View 上进行渲染。
    Widget view = AgoraRtcEngine.createNativeView(uid, (viewId){
    
    
        setState(() {
    
    
           _getVideoUidSession(uid).viewId = viewId;
           if(successCreate != null){
    
    
             successCreate(viewId);
           }
        });
    });


    //增加视频会话对象 为了视频需要(通过uid和容器信息)
    VideoUserSession videoUserSession = VideoUserSession(uid, view: view);
    _userSessions.add(videoUserSession);


  }

也是通过集合来存放管理会话对象信息,就是为了方便视频布局。

3.3.设置本地视图

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-l2JHiHZr-1602401802467)(D:\CSDN\pic\天池\pic\demo8.jpg)]

官方文档的意思是设置本地视频视图并配置本地设备上的视频显示设置:

    //设置本地视图。 该方法设置本地视图。App 通过调用此接口绑定本地视频流的显示视图 (View),并设置视频显示模式。
    // 在 App 开发中,通常在初始化后调用该方法进行本地视频设置,然后再加入频道。退出频道后,绑定仍然有效,如果需要解除绑定,可以指定空 (null) View 调用
    //该方法设置本地视频显示模式。App 可以多次调用此方法更改显示模式。
    //RENDER_MODE_HIDDEN(1):优先保证视窗被填满。视频尺寸等比缩放,直至整个视窗被视频填满。如果视频长宽与显示窗口不同,多出的视频将被截掉
    AgoraRtcEngine.setupLocalVideo(viewId, VideoRenderMode.Hidden);

并且制定视频渲染模式。

3.4.开启视频预览

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Zo0r39HM-1602401802471)(D:\CSDN\pic\天池\pic\demo9.jpg)]

加入频道之前启动本地视频预览,当然调用此方法之前,必须调用setupLocalVideoenableVideo

3.5.加入频道

当一切准备就绪后就要加入视频房间,加入视频房间和加入语音房间是一样的:

    //加入频道 第一个参数是 token 第二个是频道id 第三个参数 频道信息 一般为空 第四个 用户id
    AgoraRtcEngine.joinChannel(null, widget.channelName, null, 0);

3.6.设置事件监听

设置事件监听视频和语音最大一点不一样就是,多了设置远程用户的视频视图,这个方法主要是此方法将远程用户绑定到视频显示窗口(为指定的远程用户设置视图uid)。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-UNRMoFx4-1602401802473)(D:\CSDN\pic\天池\pic\demo10.jpg)]

这个方法要在用户加入的回调方法中调用:

//设置事件监听
  void setAgoreEventListener(){
    
    
    //成功加入房间
    AgoraRtcEngine.onJoinChannelSuccess = (String channel,int uid,int elapsed){
    
    
      print("成功加入房间,频道号:$channel");
    };

    //监听是否有新用户加入
    AgoraRtcEngine.onUserJoined = (int uid,int elapsed){
    
    
      print("新用户所加入的id为:$uid");
      setState(() {
    
    
        _createDrawView(uid, (viewId){
    
    
          //设置远程用户的视频视图

          AgoraRtcEngine.setupRemoteVideo(viewId, VideoRenderMode.Hidden, uid);
        });
      });

    };

    //监听用户是否离开这个房间
    AgoraRtcEngine.onUserOffline = (int uid,int reason){
    
    
      print("用户离开的id为:$uid");
      setState(() {
    
    
        _removeRenderView(uid);
      });

    };

    //监听用户是否离开这个频道
    AgoraRtcEngine.onLeaveChannel  =  (){
    
    
      print("用户离开");
    };

  }

3.7.布局实现

这里要分情况,1-5各用户的情况:

//视频视图布局
  Widget _videoLayout(){
    
    
    //先获取视频试图个数
    List<Widget> views = _getRenderViews();

    switch(views.length){
    
    
      //只有一个用户的时候 整个屏幕
      case 1:
        return new Container(
          child: new Column(
            children: <Widget>[
              _videoView(views[0])
            ],
          ),
        );

      //两个用户的时候 上下布局 自己在上面 对方在下面
      case 2:
        return new Container(
          child: new Column(
            children: <Widget>[
              _createVideoRow([views[0]]),
              _createVideoRow([views[1]]),
            ],
          ),
        );

      //三个用户
      case 3:
        return new Container(
          child: new Column(
            children: <Widget>[
              //截取0-2 不包括2 上面一列两个 下面一个
              _createVideoRow(views.sublist(0, 2)),

              //截取2 -3 不包括3
              _createVideoRow(views.sublist(2, 3))
            ],
          ),
        );

      //四个用户
      case 4:
         return new Container(
           child: new Column(
             children: <Widget>[
               //截取0-2 不包括2 也就是0,1 上面 下面各两个用户
               _createVideoRow(views.sublist(0, 2)),

               //截取2-4 不包括4 也就是 3,4
               _createVideoRow(views.sublist(2, 4))
             ],
           ),
         );
      default:
    }
    return new Container();
  }

最核心的就是,有用户退出和加入就要更新UI视图。

3.8.最终效果

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-pl8sfsI0-1602401802475)(D:\CSDN\pic\天池\pic\demo11.jpg)]

最终效果如上图,前后摄像头切换,挂断和静音的功能效果没录进去

游戏的语音通话解决方案

很多游戏,由于有玩家间的交互,有团队作战、配合,保证玩家能够进行实时语音通话,成为极大的需求。

现在的游戏语音通话解决方案存在什么问题?

1、第三方通话APP

这种方案,第三方的APP独立在后台运行,比如YY语音手机版。这种方案存在两个问题:

  • 音效和语音的音量无法控制在一个合理比例,无法统一调节;
  • APP在后台运行,会自己关闭,或者掉线。
  • 对手机性能要求很高,容易增加耗电量,造成手机发烫。

2、集成在游戏APP里的语音功能。

这种方案存在的问题是:

由于适配问题,安卓机型上游戏音效会被对方听到,或者说话会有回声

声网Agora.io是如何做的

1、采集播放。如果把采集的数据存成文件,或是交给播放,就形成一个闭环,我称他为第一闭环,也可以称为ADM(audio device module)。

2、编码解码。只有采集播放还不够,数据量太大,还要加上编码解码,进行数据压缩,采集压缩后的数据再解压缩播放,我称他为第二闭环,加上的这个编解码模块叫作ACM(audio coding module)。

3、网络模块。实现网络发送接收,ANM(audio network module),我叫他第三闭环。

4、前后处理模块。也就是第四闭环,Audio Processing module。这个模块主要实现3A引擎:回声消除AEC,增益控制AGC,噪声抑制ANS。

回声消除

声网Agora的第三代回声消除技术,通过逐个机型的适配。累计适配了几百款机型,而我们的第四代“免”适配技术保证我们实现4000款机型的适配。

声网Agora的“免”适配,免带一个小引号。声网Agora的免适配和适配相互配合,适配的机型,效果更好。不适配的机型是公版算法,基本也没有大问题,一般不会出现整句回声。只会间或的出现小回声,比如2分钟1次,或是10分钟1次的残留回声。很小的回声也会有,不想适配过的手机,你完全听不到回声。适配的机型,声网Agora有整套测试方法验证;免适配的机型,声网Agora依靠线上数据的反馈,判断“免”的效果。也正是依赖声网Agora线上数据的反馈,才能做到“免”适配。

总结

  • 整体开发来看并不是很难,按照具体的文档来做,普通的一些功能是能实现的,当然如果后面做一些比较高级的功能就要花多一点心思去研究。

  • 语音,视频效果还是不错的。

  • 有具体的详细开发,有文档开发者社区,便于开发者交流,反馈使用过程中的问题,这一点是非常nice的。

  • 声网,几乎是中国这一领域的顶级公司,但是仍面临着巨大挑战,正如上述的一些算法问题,真是任重道远,同时也有大批的开发者加入到声网中,成为了技术开发者,在Github中获得许多star,团结一心,会更强!

原创声明,cv调包侠,公众号进来,AI交流
在这里插入图片描述

猜你喜欢

转载自blog.csdn.net/qq_46098574/article/details/109012509