Unity C# 《有限状态机》的用法教程详解-阿里云开发者社区

开发者社区> chinar-yunxi> 正文

Unity C# 《有限状态机》的用法教程详解

简介: Unity C# 《有限状态机》的用法教程详解 有限状态机用法教程 本文提供全流程,中文翻译。 助力快速理解 FSM 有限状态机,完成游戏状态的切换 为新手节省宝贵的时间,避免采坑! 有限状态机简称: FSM —— 简称状态机 是表示有限个状态以及在这些状态之间的转移和动作等行为的数学模.
+关注继续查看

有限状态机用法教程


本文提供全图文流程,中文翻译。

Chinar 坚持将简单的生活方式,带给世人!

(拥有更好的阅读体验 —— 高分辨率用户请根据需求调整网页缩放比例)

Chinar —— 心分享、心创新!

助力快速理解 FSM 有限状态机,完成游戏状态的切换

给新手节省宝贵的时间,避免采坑!


Chinar 教程效果:
4_


全文高清图片,点击即可放大观看 (很多人竟然不知道)


1

Finite-state machine —— 有限状态机



有限状态机简称: FSM —— 简称状态机

是表示有限个状态以及在这些状态之间的转移和动作等行为的数学模型


其他的不多说,于我们开发者来说,状态机能通过全局来管理我们的游戏状态/人物状态

使我们的工程逻辑清晰,将游戏/项目各个状态的转换,交由状态机统一管理

极大的避免了当状态过多 / 转换状态过多时,每次都需要调用相应函数来完成转换的麻烦


对于初学者来讲,套用状态机来对状态进行管理,可能认为过于麻烦

其实不用怕,那只是因为不熟悉用法和逻辑流程导致的

熟练的运用状态机来管理我们的项目状态,是很有必要的

那会使后期,我们的工程非常便于维护


2

Foreword —— 前言()



网上的大神们为了更全面的阐述状态机的具体工作方式,他们有些说的极为详细

但对于初学者来讲,直接看这样的图解,教程,多数都是一脸懵逼

例如:(图片引用自网络大神博客)
1_
2_

众所周知 Chinar 讲的这些大神不同

Chinar 会通过一些简单的例子,来带领初学者了解并学会如何使用状态机来管理我们的工程.

师傅领进门,修行靠个人 ,一切都需要先入门后,自己再慢慢扩展,不然一切都是扯淡


3

Example —— 示例



脚本引用自 Wiki.unity3d —— 源码链接

这里 Chinar 用一个简单的游戏状态切换逻辑来说明状态机用法

MVC 设计模式


FSM 一共2个类,不需要挂载到游戏对象上

FSMState 状态父类,所有子类状态都继承与这个类

例如以下工程:我们要需要2个状态: 菜单状态 与 游戏状态

那么这两个类MenuState 和 GameState都需要继承自 FSMState

举个例子
3_

状态机脚本

using UnityEngine;
using System.Collections.Generic;
using UnityEngine.Experimental.PlayerLoop;


/// <summary>
/// Place the labels for the Transitions in this enum. —— 在此枚举中放置转换的标签。
/// Don't change the first label, NullTransition as FSMSystem class uses it. —— 不要改变第一个标签:NullTransition,因为FSMSystem类使用它。
/// </summary>
public enum Transition
{
    NullTransition = 0, // Use this transition to represent a non-existing transition in your system —— 使用此转换表示系统中不存在的转换
    Game,               //转到游戏
    Menu                //转到菜单
}

/// <summary>
/// Place the labels for the States in this enum. ——  在此枚举中放置状态的标签。
/// Don't change the first label, NullStateID as FSMSystem class uses it.不要改变第一个标签:NullStateID,因为FSMSystem类使用它。
/// </summary>
public enum StateID
{
    NullStateId = 0, // Use this ID to represent a non-existing State in your system —— 使用此ID表示系统中不存在的状态
    Menu,            //菜单
    Game             //游戏
}

/// <summary>
/// This class represents the States in the Finite State System.该类表示有限状态系统中的状态。
/// Each state has a Dictionary with pairs (transition-state) showing 每个状态都有一个对显示(转换状态)的字典
/// which state the FSM should be if a transition is fired while this state is the current state.如果在此状态为当前状态时触发转换,则FSM应处于那种状态。
/// Method Reason is used to determine which transition should be fired .方法原因用于确定应触发哪个转换。
/// Method Act has the code to perform the actions the NPC is supposed do if it's on this state.方法具有执行NPC动作的代码应该在这种状态下执行。
/// </summary>
public abstract class FSMState : MonoBehaviour
{
    public    Dictionary<Transition, StateID> map = new Dictionary<Transition, StateID>(); //字典 《转换,状态ID》
    protected StateID                         stateID;                                     //私有ID
    public    StateID                         ID                                           //状态ID
    {
        get { return stateID; }
    }


    protected GameManager manager; //保证子类状态可以访问到总控 GameManager
    public    GameManager Manager
    {
        set { manager = value; }
    }


    /// <summary>
    /// 添加转换
    /// </summary>
    /// <param name="trans">转换状态</param>
    /// <param name="id">转换ID</param>
    public void AddTransition(Transition trans, StateID id)
    {
        if (trans == Transition.NullTransition) // Check if anyone of the args is invalid —— //检查是否有参数无效
        {
            Debug.LogError("FSMState ERROR: NullTransition is not allowed for a real transition");
            return;
        }

        if (id == StateID.NullStateId)
        {
            Debug.LogError("FSMState ERROR: NullStateID is not allowed for a real ID");
            return;
        }

        if (map.ContainsKey(trans)) // Since this is a Deterministic FSM,check if the current transition was already inside the map —— 因为这是一个确定性FSM,检查当前的转换是否已经在字典中
        {
            Debug.LogError("FSMState ERROR: State " + stateID.ToString() + " already has transition " + trans.ToString() +
                           "Impossible to assign to another state");
            return;
        }

        map.Add(trans, id);
    }


    /// <summary>
    /// This method deletes a pair transition-state from this state's map. —— 该方法从状态映射中删除一对转换状态。
    /// If the transition was not inside the state's map, an ERROR message is printed. —— 如果转换不在状态映射内,则会打印一条错误消息。
    /// </summary>
    public void DeleteTransition(Transition trans)
    {
        if (trans == Transition.NullTransition) // Check for NullTransition —— 检查状态是否为空
        {
            Debug.LogError("FSMState ERROR: NullTransition is not allowed");
            return;
        }

        if (map.ContainsKey(trans)) // Check if the pair is inside the map before deleting —— 在删除之前,检查这一对是否在字典中
        {
            map.Remove(trans);
            return;
        }
        Debug.LogError("FSMState ERROR: Transition " + trans.ToString() + " passed to " + stateID.ToString() +
                       " was not on the state's transition list");
    }


    /// <summary>
    /// This method returns the new state the FSM should be if this state receives a transition and—— 如果该状态接收到转换,该方法返回FSM应该为新状态
    /// 得到输出状态
    /// </summary>
    public StateID GetOutputState(Transition trans)
    {
        if (map.ContainsKey(trans)) // Check if the map has this transition —— 检查字典中是否有这个状态
        {
            return map[trans];
        }
        return StateID.NullStateId;
    }


    /// <summary>
    /// This method is used to set up the State condition before entering it. —— 该方法用于在进入状态条件之前设置状态条件。
    /// It is called automatically by the FSMSystem class before assigning it to the current state.—— 在分配它之前,FSMSystem类会自动调用它到当前状态
    /// </summary>
    public virtual void DoBeforeEntering()
    {
    }


    /// <summary>
    /// 此方法用于在FSMSystem更改为另一个变量之前进行任何必要的修改。在切换到新状态之前,FSMSystem会自动调用它。
    /// This method is used to make anything necessary, as reseting variables
    /// before the FSMSystem changes to another one. It is called automatically
    /// by the FSMSystem before changing to a new state.
    /// </summary>
    public virtual void DoBeforeLeaving()
    {
    }


    /// <summary>
    /// 这个方法决定状态是否应该转换到它列表上的另一个NPC是对这个类控制的对象的引用
    /// This method decides if the state should transition to another on its list
    /// NPC is a reference to the object that is controlled by this class
    /// </summary>
    public virtual void Reason()
    {
    }


    /// <summary>
    /// 这种方法控制了NPC在游戏世界中的行为。
    /// NPC做的每一个动作、动作或交流都应该放在这里
    /// NPC是这个类控制的对象的引用
    /// This method controls the behavior of the NPC in the game World.
    /// Every action, movement or communication the NPC does should be placed here
    /// NPC is a reference to the object that is controlled by this class
    /// </summary>
    public virtual void Act()
    {
    }
}


/// <summary>
///  FSMSystem class represents the Finite State Machine class.FSMSystem类表示有限状态机类。
///  It has a List with the States the NPC has and methods to add, 它句有一个状态列表,NPC有添加、删除状态和更改机器当前状态的方法。
///  delete a state, and to change the current state the Machine is on.
/// </summary>
public class FSMSystem
{
    private List<FSMState> states; //状态集

    // The only way one can change the state of the FSM is by performing a transition 改变FSM状态的唯一方法是进行转换
    // Don't change the CurrentState directly 不要直接改变当前状态
    private StateID currentStateID;
    public  StateID CurrentStateID
    {
        get { return currentStateID; }
    }
    private FSMState currentState;
    public  FSMState CurrentState
    {
        get { return currentState; }
    }


    /// <summary>
    /// 默认构造函数
    /// </summary>
    public FSMSystem()
    {
        states = new List<FSMState>();
    }


    /// <summary>
    /// 设置当前状态
    /// </summary>
    /// <param name="state">初始状态</param>
    public void SetCurrentState(FSMState state)
    {
        currentState   = state;
        currentStateID = state.ID;
        state.DoBeforeEntering(); //开始前状态切换
    }


    /// <summary>
    /// This method places new states inside the FSM, —— 这个方法在FSM内部放置一个放置一个新状态
    /// or prints an ERROR message if the state was already inside the List. —— 或者,如果状态已经在列表中,则打印错误消息。
    /// First state added is also the initial state. 第一个添加的状态也是初始状态。
    /// </summary>
    public void AddState(FSMState fsmState, GameManager manager)
    {
        // Check for Null reference before deleting 删除前判空
        if (fsmState == null)
        {
            Debug.LogError("FSM ERROR: Null reference is not allowed");
        }
        else // First State inserted is also the Initial state, —— 插入的第一个状态也是初始状态,// the state the machine is in when the simulation begins —— 状态机是在模拟开始时
        {
            fsmState.Manager = manager; //给每个状态添加总控 GameManager

            if (states.Count == 0)
            {
                states.Add(fsmState);
                return;
            }


            foreach (FSMState state in states) // Add the state to the List if it's not inside it 如果状态不在列表中,则将其添加到列表中  (添加状态ID)
            {
                if (state.ID == fsmState.ID)
                {
                    Debug.LogError("FSM ERROR: Impossible to add state " + fsmState.ID.ToString() +
                                   " because state has already been added");
                    return;
                }
            }

            states.Add(fsmState);
        }
    }


    /// <summary>
    /// This method delete a state from the FSM List if it exists,  —— 这个方法从FSM列表中删除一个存在的状态,
    ///   or prints an ERROR message if the state was not on the List. —— 或者,如果状态不存在,则打印错误信息
    /// </summary>
    public void DeleteState(StateID id)
    {
        if (id == StateID.NullStateId) // Check for NullState before deleting —— 判空
        {
            Debug.LogError("FSM ERROR: NullStateID is not allowed for a real state");
            return;
        }


        foreach (FSMState state in states) // Search the List and delete the state if it's inside it  搜索列表并删除其中的状态
        {
            if (state.ID == id)
            {
                states.Remove(state);
                return;
            }
        }
        Debug.LogError("FSM ERROR: Impossible to delete state " + id.ToString() +
                       ". It was not on the list of states");
    }


    /// <summary>
    /// This method tries to change the state the FSM is in based on
    /// the current state and the transition passed. If current state
    ///  doesn't have a target state for the transition passed, 
    /// an ERROR message is printed.
    /// 该方法尝试根据当前状态和已通过的转换改变FSM所处的状态。如果当前状态没有传递的转换的目标状态,则输出错误消息。
    /// </summary>
    public void PerformTransition(Transition trans)
    {
        if (trans == Transition.NullTransition) // Check for NullTransition before changing the current state 在更改当前状态之前检查是否有NullTransition
        {
            Debug.LogError("FSM ERROR: NullTransition is not allowed for a real transition");
            return;
        }


        StateID id = currentState.GetOutputState(trans); // Check if the currentState has the transition passed as argument 检查currentState是否将转换作为参数传递
        if (id == StateID.NullStateId)
        {
            Debug.LogError("FSM ERROR: State " + currentStateID.ToString() + " does not have a target state " +
                           " for transition " + trans.ToString());
            return;
        }


        currentStateID = id; // Update the currentStateID and currentState        更新当前状态和ID
        foreach (FSMState state in states)
        {
            if (state.ID == currentStateID)
            {
                currentState.DoBeforeLeaving(); // Do the post processing of the state before setting the new one 在设置新状态之前是否对状态进行后处理
                currentState = state;
                currentState.DoBeforeEntering(); // Reset the state to its desired condition before it can reason or act 在它推动和动作之前,重置状态到它所需的条件
                break;
            }
        }
    }
}

4

Moltimode —— 多状态



菜单状态脚本:MenuState

游戏状态脚本:GameState

我们来控制这两个状态,交由状态机进行切换
举个例子

菜单脚本

/// <summary>
/// 菜单状态
/// </summary>
public class MenuState : FSMState
{
    void Awake()
    {
        stateID = StateID.Menu;
        AddTransition(Transition.Game, StateID.Game); //(菜单状态下:需要转游戏)→→添加转换,转换游戏 —— 对应游戏状态
        //map.Add(Transition.Game, StateID.Game);//上边也可这么写
    }


    void Start()
    {
        manager.View.StartButton.onClick.AddListener(OnStarGameClick);
    }


    /// <summary>
    /// 开始游戏
    /// </summary>
    public void OnStarGameClick()
    {
        manager.Fsm.PerformTransition(Transition.Game);
    }


    /// <summary>
    /// 进入该状态时
    /// </summary>
    public override void DoBeforeEntering()
    {
        manager.View.ShowMenuUi();
    }


    /// <summary>
    /// 离开该状态时
    /// </summary>
    public override void DoBeforeLeaving()
    {
        manager.View.HideMenuUi();
    }
}

游戏脚本

/// <summary>
/// 游戏状态
/// </summary>
public class GameState : FSMState
{
    void Awake()
    {
        stateID = StateID.Game;
        AddTransition(Transition.Menu, StateID.Menu); //(游戏状态下:点击暂停需要转菜单)→→添加转换,转换菜单—— 对应菜单状态
        //map.Add(Transition.Menu, StateID.Menu);//上边也可这么写
    }


    void Start()
    {
        manager.View.PauseButton.onClick.AddListener(OnPauseButton);
    }


    /// <summary>
    /// 暂停
    /// </summary>
    public void OnPauseButton()
    {
        manager.Fsm.PerformTransition(Transition.Menu);
    }


    /// <summary>
    /// 进入该状态时
    /// </summary>
    public override void DoBeforeEntering()
    {
        manager.View.ShowGameUi();
    }


    /// <summary>
    /// 离开该状态时
    /// </summary>
    public override void DoBeforeLeaving()
    {
        manager.View.HideGameUi();
    }
}

5

GameManager ——游戏总控脚本



游戏总控脚本:GameManager —— 用来控制全局游戏逻辑 (C)

我们在这个脚本中,将所有状态批量添加到状态机中

这里我通过修改,传入了 GameManager 到所有状态中

这样我们后期可以在各个状态中完成对 GameManager中函数的调用,同时节省了代码,逻辑也非常清晰


举个例子

游戏总控脚本

using UnityEngine;


/// <summary>
/// 游戏总控脚本
/// </summary>
public class GameManager : MonoBehaviour
{
    public FSMSystem Fsm;  //有限状态机系统对象
    public View      View; // 显示层


    private void Awake()
    {
        View = GameObject.FindGameObjectWithTag("View").GetComponent<View>(); //这里要给 View 游戏对象设置标签 "View"


        //添加所有状态到状态集(这里,我也通过修改,将 GameManager传到所有状态中,简化代码,便于调用)
        Fsm               = new FSMSystem();                     //调用构造函数,内部会自动初始化 状态集
        FSMState[] states = GetComponentsInChildren<FSMState>(); //找到所有 状态
        foreach (FSMState state in states)
        {
            Fsm.AddState(state, this); //将状态,逐个添加到 状态机中
        }
        MenuState menuState = GetComponentInChildren<MenuState>();
        Fsm.SetCurrentState(menuState); //默认状态是 菜单状态
    }
}

6

View —— 视图脚本



View 脚本来对我们所有 UI 元素进行赋值与管理

项目中引用了 DoTween 插件,来完成对UI简单动画的控制
举个例子

视图脚本

using DG.Tweening;
using UnityEngine;
using UnityEngine.UI;


/// <summary>
/// 视图脚本 —— 管理UI元素
/// </summary>
public class View : MonoBehaviour
{
    private RectTransform menuUi;      //菜单页
    private RectTransform gameUi;      //游戏页
    public  Button        StartButton; //开始按钮
    public  Button        PauseButton; //暂停按钮
    public  Ease          PubEase;


    /// <summary>
    /// 初始化函数
    /// </summary>
    void Awake()
    {
        menuUi      = (RectTransform) Find("Menu Ui");
        gameUi      = (RectTransform) Find("Game Ui");
        StartButton = Find("Menu Ui/Menu Button").GetComponent<Button>();
        PauseButton = Find("Game Ui/Pause Button").GetComponent<Button>();
    }


    /// <summary>
    /// 显示菜单页
    /// </summary>
    public void ShowMenuUi()
    {
        menuUi.DOScale(new Vector3(0.3f, 0.3f, 0.3f), 0.1f).OnComplete(() =>
        {
            menuUi.DOScale(Vector3.one, 0.3f);
            StartButton.enabled = true;
        }).SetEase(PubEase);
        menuUi.DOAnchorPos(Vector2.zero, 0.3f).SetEase(PubEase);
    }


    /// <summary>
    /// 隐藏菜单页
    /// </summary>
    public void HideMenuUi()
    {
        menuUi.DOScale(new Vector3(0.3f, 0.3f, 0.3f), 0.1f).OnComplete(() =>
        {
            menuUi.DOAnchorPos(new Vector2(-600, -450), 0.3f);
            menuUi.DOScale(Vector3.zero, 0.3f).OnComplete(() => { StartButton.enabled = false; }).SetEase(PubEase);
        }).SetEase(PubEase);
    }


    /// <summary>
    /// 显示游戏页
    /// </summary>
    public void ShowGameUi()
    {
        gameUi.DOScale(new Vector3(0.3f, 0.3f, 0.3f), 0.1f).OnComplete(() =>
        {
            gameUi.DOScale(Vector3.one, 0.3f);
            PauseButton.enabled = true;
        }).SetEase(PubEase);
        gameUi.DOAnchorPos(Vector2.zero, 0.3f).SetEase(PubEase);
    }


    /// <summary>
    /// 隐藏游戏页
    /// </summary>
    public void HideGameUi()
    {
        gameUi.DOScale(new Vector3(0.3f, 0.3f, 0.3f), 0.1f).OnComplete(() =>
        {
            gameUi.DOAnchorPos(new Vector2(-600, -450), 0.3f);
            gameUi.DOScale(Vector3.zero, 0.3f).OnComplete(() => { PauseButton.enabled = false; }).SetEase(PubEase);
        }).SetEase(PubEase);
    }


    /// <summary>
    /// 查找对Ui元素完成赋值
    /// </summary>
    /// <param name="uiElement">Ui名查找路径</param>
    Transform Find(string uiElement)
    {
        return transform.Find("Canvas/" + uiElement);
    }
}

7

Final effect —— 最终效果



我们通过状态机简单的完成了 开始游戏 和暂停的状态切换

代码中注释写的非常详细了,请初学者认真看下

具体流程就是:

1. GameManager 完成将所有子类状态添加到状态集中

2. View 获取到我们所需要的所有 UI 元素对象,并提供公有方法可供各个状态访问

3. 做好各个状态的进入 与离开时机发生时,该执行的事件,交由状态机去管理!

状态进入:DoBeforeEntering()
状态离开:DoBeforeLeaving()

4. 例子较为简单,为了方便初学者理解学习只写了2个状态

根据需求,大家可以举一反三,多谢几个状态练习一下,其实流程很简单!


8

Project —— 项目文件



项目文件为 unitypackage 文件包:

下载导入 Unity 即可使用

举个例子

点击下载 —— 项目资源 (积分支持)

点击下载 —— 项目资源 (Chinar免费)

最终效果: (由于GIF录制 60帧数的限制,所以我点击太快了,看着有些卡似得)
4_

至此:状态机教程结束


其他教程

May Be —— 搞开发,总有一天要做的事!


拥有自己的服务器,无需再找攻略!

Chinar 提供一站式教程,闭眼式创建!

为新手节省宝贵时间,避免采坑!




1 —— 云服务器包年包月 - 超全教程 (新手必备!)


2 —— 阿里ECS云服务器自定义配置 - 购买教程(新手必备!)


3—— Windows 服务器配置、运行、建站一条龙 !


4 —— Linux 服务器配置、运行、建站一条龙 !


4ea10730ae9d84a387f47a97930b56ba644af91c
$ $
技术交流群:806091680 ! Chinar 欢迎你的加入

END

本博客为非营利性个人原创,除部分有明确署名的作品外,所刊登的所有作品的著作权均为本人所拥有,本人保留所有法定权利。违者必究

对于需要复制、转载、链接和传播博客文章或内容的,请及时和本博主进行联系,留言,Email: ichinar@icloud.com

对于经本博主明确授权和许可使用文章及内容的,使用时请注明文章或内容出处并注明网址

版权声明:本文内容由阿里云实名注册用户自发贡献,版权归原作者所有,阿里云开发者社区不拥有其著作权,亦不承担相应法律责任。具体规则请查看《阿里云开发者社区用户服务协议》和《阿里云开发者社区知识产权保护指引》。如果您发现本社区中有涉嫌抄袭的内容,填写侵权投诉表单进行举报,一经查实,本社区将立刻删除涉嫌侵权内容。

相关文章
Unity教程:GUI 界面开发
UI概述: UI永远是显示在屏幕的最前面上,不受变形、碰撞、光照的影响GUI概述:GUI是Graphical User Interface的缩写。Unity的图形界面系统能容易和快速创建出各种交互界面。
1775 0
(四):C++分布式实时应用框架——状态中心模块
C++分布式实时应用框架——状态中心模块   上篇:(三):C++分布式实时应用框架——系统管理模块     技术交流合作QQ群:436466587 欢迎讨论交流     版权声明:本文版权及所用技术归属smartguys团队所有,对于抄袭,非经同意转载等行为保留法律追究的权利!     状态中心是分布式系统中不可或缺的部分。
1130 0
详细图文教程---教你如何通过云小站选择阿里云ECS服务器配置
对于有一定用户的网站来说,选择服务器来建网站势在必行。服务器的配置项很多,很多服务器使用新手并不知道该如何正确的去选择服务器配置。下面学就以阿里云ECS服务器为例,教大家如何选择阿里云ECS服务器配置。
645 0
matlab 工具之各种降维方法工具包,下载及使用教程,有PCA, LDA, 等等。。。
最近跑深度学习,提出的feature是4096维的,放到我们的程序里,跑得很慢,很慢。。。。 于是,一怒之下,就给他降维处理了,但是matlab 自带的什么pca( ), princomp( )函数,搞不清楚怎么用的,表示不大明白,下了一个软件包: 名字:Matlab Toolbox for Dimensionality Reduction 链接:http://lvdmaaten.
2147 0
阿里云服务器端口号设置
阿里云服务器初级使用者可能面临的问题之一. 使用tomcat或者其他服务器软件设置端口号后,比如 一些不是默认的, mysql的 3306, mssql的1433,有时候打不开网页, 原因是没有在ecs安全组去设置这个端口号. 解决: 点击ecs下网络和安全下的安全组 在弹出的安全组中,如果没有就新建安全组,然后点击配置规则 最后如上图点击添加...或快速创建.   have fun!  将编程看作是一门艺术,而不单单是个技术。
4519 0
Android开发教程 - 使用Data Binding Android Studio不能正常生成相关类/方法的解决办法
本系列目录 使用Data Binding(一)介绍 使用Data Binding(二)集成与配置 使用Data Binding(三)在Activity中的使用 使用Data Binding(四)在Fragment中的使用 ...
984 0
+关注
chinar-yunxi
有人天生为王、有人败者为寇 脚下的路如果不是自己的选择,那么旅途的终点在哪?也就没人知道....
65
文章
1
问答
文章排行榜
最热
最新
相关电子书
更多
文娱运维技术
立即下载
《SaaS模式云原生数据仓库应用场景实践》
立即下载
《看见新力量:二》电子书
立即下载