多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Unity响应式编程实战:UniRx核心概念与游戏开发应用

Unity响应式编程实战:UniRx核心概念与游戏开发应用 1. 项目概述为什么Unity开发者绕不开UniRx如果你在Unity社区里混迹过一段时间或者最近在面试Unity岗位大概率会听到一个词UniRx。它可能出现在同事讨论如何优雅处理UI事件时也可能出现在面试官追问“如何管理复杂的游戏状态流”时。我第一次接触UniRx是因为一个看似简单却让我焦头烂额的需求一个角色技能系统需要根据玩家的血量、能量、Buff状态以及按键组合实时计算并显示当前可释放的技能列表及其效果预览。用传统的Update轮询和一堆if-else代码很快就变成了“面条代码”难以维护和调试。直到我发现了UniRx它提供了一种全新的编程范式——响应式编程Reactive Programming彻底改变了我在Unity中处理异步逻辑和数据流的方式。简单来说UniRx是专门为Unity量身定做的响应式编程扩展库。它把游戏中的一切动态变化——从玩家的每一次点击、角色的每一点血量变动、到网络消息的每一次到达——都抽象成“数据流”。你的任务不再是去“轮询”或“等待”这些变化而是去“订阅”这些流并声明当数据流来临时应该做什么。这就像从“不断打电话问快递到哪了”变成了“快递到了自动给你发短信通知”后者显然更高效、更省心。对于Unity开发者而言掌握UniRx不再是“加分项”而是处理现代游戏复杂交互、UI逻辑和异步操作的“必备技能”。它能显著提升代码的可读性、可维护性并大幅减少由时序问题引发的Bug。2. UniRx核心概念拆解从“流”与“观察者”说起要玩转UniRx必须先吃透它的几个核心思想。这些概念是理解后续所有操作的基础。2.1 万物皆流IObservable 与 IObserver这是响应式编程的基石。在UniRx的世界里任何随时间推移而产生一系列数据的事件都可以被看作一个“可观察序列”Observable Sequence由IObservableT接口表示。T是这个流中传递的数据类型。比如鼠标点击事件是一个IObservableUnit流Unit类似void表示无具体数据只有事件发生。角色血量变化是一个IObservablefloat流每次发射当前血量值。一个从服务器下载进度的协程可以转换为IObservablefloat流持续发射0到1的进度值。与“流”对应的是“观察者”IObserverT它负责订阅流并对流发出的数据OnNext、错误OnError和完成信号OnCompleted做出反应。不过在实际使用中我们很少直接实现这个接口而是通过Subscribe方法来定义回调。// 假设有一个发射整数1,2,3然后结束的流 IObservableint numberStream Observable.Range(1, 3); // 订阅这个流并定义观察者的行为 IDisposable subscription numberStream.Subscribe( onNext: number Debug.Log($收到数据: {number}), onError: error Debug.LogError($发生错误: {error}), onCompleted: () Debug.Log(流结束了) ); // 输出 // 收到数据: 1 // 收到数据: 2 // 收到数据: 3 // 流结束了关键理解Subscribe方法返回一个IDisposable对象它代表了这个订阅关系。当你不再需要接收这个流的数据时例如对象被销毁必须调用Dispose()来取消订阅否则会导致内存泄漏。这是UniRx使用中最常见的坑之一。2.2 操作符对流进行变形、过滤与组合如果只是订阅原始流那价值有限。UniRx的强大之处在于它提供了上百个“操作符”Operators允许你像处理集合LINQ一样处理异步数据流。这是实现复杂逻辑的关键。转换类Select(类似LINQ的Select)将流中的数据转换成另一种形式。Observable.EveryUpdate() // 每帧发射一个长整型时间戳的流 .Select(_ Input.mousePosition) // 转换成鼠标位置的流 .Subscribe(pos Debug.Log(pos));过滤类Where(类似LINQ的Where)只允许满足条件的数据通过。Observable.EveryUpdate() .Where(_ Input.GetMouseButtonDown(0)) // 只有鼠标左键按下时流才通过 .Subscribe(_ Debug.Log(点击了));合并类Merge,Zip,CombineLatest等用于组合多个流。IObservablebool streamA ...; IObservablebool streamB ...; // CombineLatest: 当任意一个流发出新值时取两个流最新的值组合发射 streamA.CombineLatest(streamB, (a, b) a b) .Subscribe(result Debug.Log($A and B are both true? {result}));实操心得刚开始不要试图记住所有操作符。掌握最常用的Select、Where、Merge、CombineLatest、Throttle、DistinctUntilChanged等就能解决80%的问题。其他操作符在需要时查阅文档即可。2.3 UniRx与Unity生命周期的深度集成这是UniRx区别于其他响应式库如System.Reactive的最大优势。它原生提供了与MonoBehaviour生命周期绑定的流让响应式编程无缝融入Unity开发。生命周期事件流this.UpdateAsObservable(),this.OnDestroyAsObservable()等。这让你可以用响应式的方式编写原本在Update或OnDestroy中的逻辑并且自动管理订阅生命周期。public class PlayerController : MonoBehaviour { void Start() { // 订阅Update流自动在GameObject销毁时取消订阅 this.UpdateAsObservable() .Where(_ Input.GetKey(KeyCode.Space)) .Subscribe(_ Jump()); // 在OnDestroy时自动执行的逻辑 this.OnDestroyAsObservable() .Subscribe(_ Debug.Log(PlayerController被销毁了)); } }UI事件流对于UGUIUniRx.Triggers命名空间提供了各种UI事件的Observable如Button.OnClickAsObservable()。public Button myButton; void Start() { myButton.OnClickAsObservable() .Subscribe(_ Debug.Log(按钮被点击了)); }注意使用this.UpdateAsObservable()这类扩展方法时其订阅会在该MonoBehaviour被销毁时自动终止。但如果你订阅的是全局的静态事件或其它GameObject的事件务必手动管理IDisposable或在OnDestroy中统一取消订阅这是避免内存泄漏的重中之重。3. 实战演练用UniRx重构典型游戏场景理解了概念我们通过几个具体的场景来看看UniRx如何化繁为简。3.1 场景一智能角色血量显示与预警系统传统做法在Update里判断当前血量与最大血量的比例然后更新UI血条和颜色可能还要判断是否低于警戒线播放警告音效。逻辑分散难以阅读。UniRx响应式做法将血量视为一个流任何修改血量的操作受伤、治疗都通过这个流来发射新值。UI和音效逻辑作为这个流的订阅者。public class ReactiveHealthSystem : MonoBehaviour { // 1. 定义可观察的数据源通常使用ReactiveProperty或Subject public ReactivePropertyfloat CurrentHealth new ReactivePropertyfloat(100f); public float MaxHealth 100f; public Image healthBarImage; public Color fullHealthColor Color.green; public Color lowHealthColor Color.red; public float lowHealthThreshold 0.3f; // 低于30%为低血量 public AudioClip lowHealthWarningSound; private AudioSource audioSource; private CompositeDisposable disposables new CompositeDisposable(); void Start() { audioSource GetComponentAudioSource(); // 2. 计算血量比例流 IObservablefloat healthRatioStream CurrentHealth .Select(health Mathf.Clamp01(health / MaxHealth)) // 转换为比例 .DistinctUntilChanged() // 仅在比例实际变化时发射避免每帧重复计算 .Publish() // 共享此流避免多个订阅导致重复计算 .RefCount(); // 3. 订阅并更新UI血条 healthRatioStream .Subscribe(ratio { healthBarImage.fillAmount ratio; healthBarImage.color Color.Lerp(lowHealthColor, fullHealthColor, ratio); }) .AddTo(disposables); // 统一管理订阅 // 4. 低血量警告逻辑 healthRatioStream .Select(ratio ratio lowHealthThreshold) // 转换为布尔流是否低血量 .DistinctUntilChanged() // 仅在状态改变时发射从正常到低血或从低血恢复正常 .Subscribe(isLowHealth { if (isLowHealth) { Debug.LogWarning(低血量警告); // 播放警告音效这里使用Throttle避免音效重复播放过于频繁 Observable.EveryUpdate() .Where(_ isLowHealth) // 只在低血量状态下 .ThrottleFirst(TimeSpan.FromSeconds(5)) // 每5秒最多触发一次 .Subscribe(_ audioSource.PlayOneShot(lowHealthWarningSound)) .AddTo(disposables); } else { Debug.Log(血量恢复正常。); } }) .AddTo(disposables); } // 外部调用此方法来改变血量 public void TakeDamage(float damage) { CurrentHealth.Value Mathf.Max(0, CurrentHealth.Value - damage); } void OnDestroy() { // 5. 一次性取消所有订阅 disposables?.Dispose(); } }方案解析ReactivePropertyT这是UniRx提供的“响应式属性”它本身就是一个IObservableT。当它的Value被设置时会自动向所有订阅者推送新值。它是连接传统变量和响应式世界的桥梁。DistinctUntilChanged()这是一个极其重要的操作符。它确保只在数据真正发生变化时才向下游发射。对于血量比例这种可能连续多帧不变的数据它能避免不必要的UI更新和计算提升性能。Publish().RefCount()这是一个“流共享”模式。当有多个订阅者订阅healthRatioStream时如果没有这个操作Select中的计算会执行多次。Publish().RefCount()确保了上游的计算只执行一次结果分发给所有订阅者。ThrottleFirst用于限制事件触发的频率。这里保证低血量警告音效每5秒最多播放一次避免玩家被持续不断的警告音效干扰。CompositeDisposable这是一个“订阅容器”。通过.AddTo(disposables)将每个订阅添加到容器中最后在OnDestroy里一句disposables.Dispose()就能取消所有订阅是管理多个订阅的最佳实践能有效防止遗漏。3.2 场景二处理复杂的用户输入与技能冷却需求实现一个技能按钮长按超过1秒激活蓄力技能短按则释放瞬发技能。同时技能有冷却时间冷却期间按钮不可交互。public class SkillButtonController : MonoBehaviour { public Button skillButton; public Image cooldownOverlayImage; // 用于显示冷却遮罩 public float cooldownTime 3.0f; private float currentCooldown 0f; private ReactivePropertybool isOnCooldown new ReactivePropertybool(false); private CompositeDisposable buttonSubscriptions new CompositeDisposable(); void Start() { // 1. 将按钮的按下和抬起事件转换为流 var pointerDownStream skillButton.OnPointerDownAsObservable().Select(_ Time.time); var pointerUpStream skillButton.OnPointerUpAsObservable().Select(_ Time.time); // 2. 计算按下持续时间 var pressDurationStream pointerDownStream .Select(downTime pointerUpStream .First() // 取第一次抬起事件 .Select(upTime upTime - downTime) // 计算时长 .Timeout(TimeSpan.FromSeconds(5)) // 防止永不抬起5秒超时 .CatchIgnore((TimeoutException _) Observable.Return(5f)) // 超时视为长按5秒 ) .Switch(); // 每次新的按下事件都会切换到新的时长计算流 // 3. 根据按下时长决定技能类型 pressDurationStream .Where(duration !isOnCooldown.Value) // 冷却中不触发 .Subscribe(duration { if (duration 1.0f) { Debug.Log($释放蓄力技能蓄力时间{duration:F2}秒); StartCoroutine(PerformChargedSkill(duration)); } else { Debug.Log(释放瞬发技能); StartCoroutine(PerformInstantSkill()); } // 触发冷却 StartCoroutine(StartCooldown()); }) .AddTo(buttonSubscriptions); // 4. 响应冷却状态更新按钮交互性和UI isOnCooldown .Subscribe(onCooldown { skillButton.interactable !onCooldown; cooldownOverlayImage.gameObject.SetActive(onCooldown); if (onCooldown) { StartCoroutine(UpdateCooldownUI()); } }) .AddTo(buttonSubscriptions); } private IEnumerator StartCooldown() { isOnCooldown.Value true; currentCooldown cooldownTime; while (currentCooldown 0) { currentCooldown - Time.deltaTime; yield return null; } isOnCooldown.Value false; } private IEnumerator UpdateCooldownUI() { while (isOnCooldown.Value) { cooldownOverlayImage.fillAmount currentCooldown / cooldownTime; yield return null; } cooldownOverlayImage.fillAmount 0; } private IEnumerator PerformChargedSkill(float chargeTime) { /* 蓄力技能逻辑 */ yield break; } private IEnumerator PerformInstantSkill() { /* 瞬发技能逻辑 */ yield break; } void OnDestroy() { buttonSubscriptions?.Dispose(); } }技术要点解析复杂事件流建模这个例子展示了如何将“按下-抬起”这一对事件建模为一个发射“按下时长”的单一流。这里使用了SelectFirstSwitch的组合。Select(downTime ...)每次按下事件发生时启动一个新的内部流来计算这次按下的时长。First()内部流等待第一次抬起事件。Switch()这是关键。当一次新的按下事件发生时它会自动取消订阅前一个尚未完成的内部流比如用户还没抬起就又按下了并切换到为这次新按下创建的计算流。这完美处理了连续快速点击或取消的情况。Timeout与错误处理Timeout操作符为流设置一个超时时间。如果指定的时间内没有事件比如用户按住不放流会抛出一个TimeoutException。我们用CatchIgnore捕获这个异常并返回一个代表“长按”的默认值5秒。这使得逻辑更加健壮。状态管理使用ReactivePropertybool isOnCooldown来管理冷却状态。所有依赖于冷却状态的逻辑按钮交互、UI显示都通过订阅这个属性来实现状态改变时所有相关部分自动更新避免了状态不一致的Bug。3.3 场景三网络请求与数据绑定假设我们需要从服务器加载玩家排行榜数据并在UI上显示一个加载动画加载成功后更新列表失败则显示错误信息。public class LeaderboardView : MonoBehaviour { public GameObject loadingPanel; public Text errorText; public Transform contentParent; public GameObject entryPrefab; public Button refreshButton; private readonly string apiUrl https://api.yourgame.com/leaderboard; private CompositeDisposable disposables new CompositeDisposable(); void Start() { // 1. 将刷新按钮点击事件转换为请求流 refreshButton.OnClickAsObservable() .Select(_ Unit.Default) // 转换为Unit流 .StartWith(Unit.Default) // 立即触发一次用于初始加载 .SelectMany(_ FetchLeaderboardDataObservable()) // 将点击事件“扁平化”为网络请求结果流 .SubscribeOn(Scheduler.ThreadPool) // 在后台线程执行网络请求 .ObserveOnMainThread() // 在主线程处理结果 .Subscribe( onNext: data OnDataLoaded(data), onError: error OnDataLoadFailed(error) ) .AddTo(disposables); } // 2. 将UnityWebRequest封装为Observable private IObservableListLeaderboardEntry FetchLeaderboardDataObservable() { return Observable.FromCoroutineListLeaderboardEntry(observer StartCoroutine(FetchDataCoroutine(observer))); } private IEnumerator FetchDataCoroutine(IObserverListLeaderboardEntry observer) { loadingPanel.SetActive(true); errorText.gameObject.SetActive(false); using (UnityWebRequest request UnityWebRequest.Get(apiUrl)) { yield return request.SendWebRequest(); #if UNITY_2020_3_OR_NEWER if (request.result ! UnityWebRequest.Result.Success) #else if (request.isNetworkError || request.isHttpError) #endif { observer.OnError(new System.Exception(request.error)); } else { // 解析JSON数据 var entries JsonUtility.FromJsonLeaderboardData(request.downloadHandler.text).entries; observer.OnNext(entries); observer.OnCompleted(); } } } private void OnDataLoaded(ListLeaderboardEntry entries) { loadingPanel.SetActive(false); // 清空旧列表 foreach (Transform child in contentParent) Destroy(child.gameObject); // 生成新列表项 foreach (var entry in entries) { var go Instantiate(entryPrefab, contentParent); go.GetComponentLeaderboardEntryUI().Bind(entry); } } private void OnDataLoadFailed(System.Exception error) { loadingPanel.SetActive(false); errorText.gameObject.SetActive(true); errorText.text $加载失败: {error.Message}; Debug.LogError(error); } void OnDestroy() disposables?.Dispose(); }核心优势体现SelectMany(或FlatMap)这是处理“异步操作中的异步操作”的神器。按钮点击流IObservableUnit中的每一个事件都被“映射”为一个新的异步网络请求流IObservableListLeaderboardEntry。SelectMany会将这些内部流“拍平”将所有内部流的结果按顺序输出到最终的单一流中。这避免了回调地狱。线程调度SubscribeOn(Scheduler.ThreadPool)和ObserveOnMainThread()是黄金组合。前者指定网络请求在后台线程池中执行不阻塞主线程后者指定结果处理更新UI必须在Unity的主线程中执行因为Unity的API不是线程安全的。UniRx帮你优雅地处理了线程切换。Observable.FromCoroutine这是将传统的Unity协程IEnumerator包装成IObservableT的标准方法。它允许你将任何基于协程的异步操作如UnityWebRequest、WWW、AsyncOperation无缝集成到响应式链中。4. 高级模式与性能优化指南当你熟悉基础操作后以下模式能让你写出更高效、更模块化的UniRx代码。4.1 ReactiveProperty 与 ReactiveCommandMVVM模式的基石UniRx常被用于在Unity中实现简化的MVVMModel-View-ViewModel模式这对于构建复杂的UI系统特别有用。ReactivePropertyT如前所述它是可绑定的数据模型。ViewUI订阅ViewModel的ReactiveProperty实现数据驱动UI更新。ReactiveCommand表示一个可以执行的命令通常与按钮绑定。它自带一个IObservablebool类型的CanExecute属性用于控制命令是否可执行如按钮是否可点击。// 一个简单的计数器ViewModel public class CounterViewModel { public ReactivePropertyint Count { get; } new ReactivePropertyint(0); public ReactiveCommand IncrementCommand { get; } public ReactiveCommand DecrementCommand { get; } public CounterViewModel() { // 命令在Count 0时可执行防止减到负数 IncrementCommand new ReactiveCommand(); DecrementCommand Count.Select(c c 0).ToReactiveCommand(); // 订阅命令执行 IncrementCommand.Subscribe(_ Count.Value); DecrementCommand.Subscribe(_ Count.Value--); } } // 在View (MonoBehaviour) 中绑定 public class CounterView : MonoBehaviour { public Text countText; public Button incrementButton; public Button decrementButton; private CounterViewModel viewModel new CounterViewModel(); private CompositeDisposable disposables new CompositeDisposable(); void Start() { // 绑定数据 viewModel.Count .Subscribe(count countText.text $Count: {count}) .AddTo(disposables); // 绑定命令到按钮 viewModel.IncrementCommand.BindTo(incrementButton).AddTo(disposables); viewModel.DecrementCommand.BindTo(decrementButton).AddTo(disposables); } void OnDestroy() disposables?.Dispose(); }这种模式将业务逻辑ViewModel与视图表现View彻底分离ViewModel不依赖于任何Unity对象易于单元测试。UI只是ViewModel状态的可视化反映。4.2 流的生命周期与资源管理避免内存泄漏这是UniRx新手最容易犯错的地方。订阅一个流而不取消意味着观察者会一直持有对流的引用即使GameObject已经被销毁它也无法被垃圾回收。黄金法则为每个MonoBehaviour创建一个CompositeDisposable实例通常命名为_disposables或compositeDisposable在该脚本的所有订阅后都加上.AddTo(disposables)。在OnDestroy方法中调用disposables.Dispose()。public class SafeUniRxUser : MonoBehaviour { private CompositeDisposable disposables new CompositeDisposable(); void Start() { Observable.Interval(TimeSpan.FromSeconds(1)) .Subscribe(tick Debug.Log(tick)) .AddTo(disposables); // 关键 SomeGlobalEventStream .Subscribe(_ DoSomething()) .AddTo(disposables); // 关键 } void OnDestroy() { // 一次性取消所有订阅 disposables?.Dispose(); } }特殊情况处理静态事件或全局流务必手动管理订阅。可以考虑在订阅时记录IDisposable或者在脚本中提供一个UnsubscribeAll方法供外部调用。AddTo(this)如果你确定流的生命周期和这个GameObject绑定可以使用.AddTo(this)它会在该GameObject被销毁时自动取消订阅。但个人更推荐显式使用CompositeDisposable控制力更强。4.3 性能敏感场景下的操作符选择UniRx非常高效但在每帧都发射事件的高频流如Observable.EveryUpdate()中操作符的选择会影响性能。WherevsFirst/Take如果条件很快就能满足使用First(predicate)或Take(1)可能在找到目标后立即停止发射比持续运行的Where更高效。DistinctUntilChanged是你的朋友在将流绑定到UI更新时务必使用它。避免在数据未变化时触发昂贵的UI重建。警惕Subscribe内的耗时操作Subscribe回调会在流发射的同一线程同步执行。如果回调很慢会阻塞整个流。对于耗时操作考虑使用ObserveOn(Scheduler.ThreadPool)将其调度到后台线程或者使用SelectMany将其转换为另一个异步流。对于高频事件考虑采样如果你不需要每一帧的数据可以使用Sample操作符进行采样。// 每0.1秒采样一次鼠标位置而不是每帧 Observable.EveryUpdate() .Select(_ Input.mousePosition) .Sample(TimeSpan.FromSeconds(0.1)) .Subscribe(pos UpdateSomeVisualEffect(pos));5. 常见问题排查与调试技巧即使经验丰富在复杂的数据流中定位问题也可能令人头疼。以下是一些实用的调试技巧。5.1 我的流为什么没有触发这是最常见的问题。请按以下清单排查订阅了吗确认你确实调用了.Subscribe(...)。没有订阅的流就像没有插电源的电器。订阅时机对吗如果你在Start或Awake中订阅一个基于MonoBehaviour生命周期的流如this.UpdateAsObservable()确保脚本的GameObject是激活的。流有数据发射吗使用Do操作符在流的各个阶段插入日志这是最强大的调试工具。someStream .Do(onNext: x Debug.Log($原始数据: {x})) .Where(x x 10) .Do(onNext: x Debug.Log($过滤后: {x})) // 如果这里没日志说明Where条件没满足 .Subscribe(...);取消订阅了吗检查是否在某个地方意外调用了Dispose()或者CompositeDisposable被提前清理了。使用了Publish().RefCount()吗如果多个订阅者订阅了同一个“冷”流Cold Observable而该流包含网络请求等副作用可能会触发多次。使用Publish().RefCount()确保副作用只发生一次。但如果第一个订阅在第二个订阅开始前就结束了RefCount会导致流重置第二个订阅会触发新的副作用。这时可能需要使用Replay(1).RefCount()来重播最后一个值。5.2 内存泄漏如何定位怀疑有内存泄漏时可以使用Unity Profiler的Memory视图查看MonoBehaviour或自定义类的实例数量是否只增不减。确保所有通过Subscribe返回的IDisposable都被妥善管理尤其是在动态创建和销毁对象的场景中。检查事件委托传统的C#事件 (event Action) 如果使用了订阅在销毁时也必须使用-取消订阅。UniRx的FromEvent或FromEventPattern可以帮助你将传统事件转换为更容易管理的Observable。5.3 如何处理异常流中的异常如果不处理会导致整个流终止。使用Catch、OnErrorRetry等操作符进行错误处理。dangerousNetworkRequestStream .Catch((HttpRequestException ex) { Debug.LogError($网络请求失败: {ex.Message}); return Observable.Return(default(ResponseData)); // 返回一个默认值让流继续 }) .OnErrorRetry((Exception ex) Debug.LogWarning($重试中... 错误: {ex.Message}), retryCount: 3) // 重试3次 .Subscribe(...);5.4 UniRx与Unity协程、async/await如何选择这是另一个常见问题。三者各有适用场景UniRx最适合处理基于事件的数据流和状态随时间变化的逻辑。当你有多个相互依赖、需要组合、过滤或基于时间操作的异步事件时UniRx是首选。协程 (Coroutine)适合处理顺序执行的、基于帧的异步任务比如播放一连串动画、按顺序移动物体。逻辑直观但难以组合和取消需要手动管理Coroutine句柄。async/await (C#)在Unity 2017.1中支持较好。适合处理单次的、基于任务的异步操作特别是涉及Task的API如某些AssetBundle加载、文件IO。代码写法最接近同步易于理解。最佳实践是混合使用用UniRx作为顶层的“粘合剂”和事件流处理器在其内部用Observable.FromCoroutine或Observable.FromAsync来封装协程或async方法从而享受响应式编程的组合优势同时利用协程和async在各自领域的便利性。我个人在实际项目中的体会是一旦习惯了用“流”的视角来思考游戏逻辑很多复杂的问题会变得清晰起来。开始时可能会觉得有些抽象但坚持写几个小例子后你会发现自己再也回不去那种到处是回调、状态难以追踪的代码风格了。UniRx不是银弹但它绝对是Unity中级开发者向高级进阶道路上必须熟练掌握的一把利器。最后一个小技巧多使用Do操作符来打印日志可视化你的数据流这对于理解和调试复杂流逻辑有奇效。
返回列表