본문으로 건너뛰기

"unirx" 태그로 연결된 32개 게시물개의 게시물이 있습니다.

모든 태그 보기

· 약 53분
karais89

환경

  • macOS Ventura v13.0
  • Unity 2021.4.36f1
  • Github Desktop
  • Rider 2022.1
  • UniRx v7.1.0

원문 : 2022年現在におけるUniRxの使いみち

이 포스팅은 원문을 단순히 구글 번역을 하여 정리한 내용입니다. 일본어를 잘하시는 분은 원문을 보시는게 더 좋으실 것 같습니다.


소개

제가 "UniRx 입문"이라는 기사를 쓰기 시작한 지 5년, 마지막 기사를 쓴 지 4년이 지났습니다. 기사를 업데이트하지 않은 지난 몇 년 동안 UniRx를 둘러싼 환경이 크게 바뀌었습니다. UniRx라는 라이브러리 자체에 특히 큰 업데이트는 없지만 Unity의 C# 버전이 업데이트되거나 UniRx보다 편리한 라이브러리가 등장하기도 했습니다.

이번은 2022년 현재의 Unity에 있어서의 UniRx의 입 위치와 그 사용법에 대해 해설합니다.

UniRx의 위치

현대 UniRx 상황

2017년경의 오래된 버전의 Unity에서는 .NET 3.5 상당히 부족한 C#의 기능 밖에 사용할 수 없었습니다. 비동기 처리에 async/await조차 사용할 수 없고, 코루틴을 사용할지 UniRx를 사용할지 선택하는 상황이었습니다. 따라서 시간 관련 처리는 코루틴이나 UniRx를 사용하여 작성할 수 밖에 없었습니다.

그렇지만, 2022년 현재에 있어서는 C# 8.0/.NET Standard 2.0와, 꽤 최신의 C#의 기능을 이용 가능하게 되었습니다. 이 때문에 무언가 기능을 구현할 때 2017년경에 비해 취할 수 있는 옵션이 대폭 늘어나고, 옛날에는 일반적이었던 수법도 현대에서는 오래된 기술이 된 것도 있습니다. UniRx도 그 중 하나입니다. 현대에서는 무리하게 UniRx를 사용할 필요가 없어서 async/await쓰거나 다른 라이브러리를 병용하는 것이 스마트하게 끝난다는 패턴이 존재하고 있습니다.

UniRx 자체는 확실히 편리하고 강력한 라이브러리이며, 사용 목적에 맞는 다면 매우 편리합니다. 하지만 UniRx 자체는 까다롭고, 다양한 기법들이 존재 하기 때문에, 잘 다루어지기까지 꽤 시간이 걸립니다. 게다가 현대에 있어서는 "그 밖에 선택사항이 많아졌기 때문에 UniRx를 무리하게 사용할 필요는 없다"라고 하는 상황이기도 합니다.

그러므로 UniRx는 Unity 초보자가 가장 먼저 학습하는 라이브러리가 아니게 되었다(async/await부터 먼저 학습하는 것이 더 실천적이다) 라는 상황입니다.

하지만 UniRx의 쓰임새가 완전히 없어진 것은 아니고, 용도에 따라서는 아직도 현역에서 편리하게 사용할 수 있는 라이브러리입니다. 이러한 전제를 근거로, 현대에 있어서의 UniRx의 용도를 정리해 보겠습니다.

현대 UniRx 애플리케이션

UniRx 자체는 "이벤트 처리"와 "비동기 처리"에 특화된 라이브러리입니다. 이 중 "이벤트 처리"에 대해서는 UniRx는 아직도 현역으로 사용할 수 있는 수준으로 편리합니다. 한편 "비동기 처리"에 대해서는 async/await를 사용할 수 있는 현대에 있어서는, UniRx를 사용하는 것은 드문 상황이지요.

또, UniRx의 특징인 풍부한 오퍼레이터군도, 딱 빠지는 상황이라면 아직도 취급할 수 있습니다. 다만 복잡한 오퍼레이터 체인이 될 정도라면, 솔직하게 async/await쓰는 편이 전체적으로 알기 쉬워지는 경우가 많습니다.

UniRx 애플리케이션

  • 이벤트 처리의 기구로서 사용
    • 여러 객체에 메시지를 전달하는 장면에서 여전히 사용할 수 있습니다.
    • MessageBroker로서의 용도도 있지만, 현대에 있어서는 "MessagePipe"라고 하는 선택사항도 있다
  • 복잡한 이벤트 처리 로직 구축
    • 딱 빠지는 오퍼레이터가 있다면 간결하게 쓸 수 있다.
    • 조금이라도 응용을 시도하면 단번에 어려워지므로 async/await 사용하는 것이 좋습니다.
  • 비동기 처리의 메커니즘으로
    • 단발 비동기 처리를 다루는 경우 현대에서는 async / await로 대체하는 것이 좋습니다.
    • Retry, Switch등의 일부 오퍼레이터가 편리하므로 그 목적으로 사용할 수도 있습니다.
  • UI 구성에 사용
    • MV(R)P 패턴이 편리

역주

  • 결론적으로는 현재 UniRx의 다양한 오퍼레이터에 대한 부분을 익히는 노력으로 다른 기술들을 익히는게 가장 좋은 선택으로 보입니다.
  • UI를 구성할때 MV(R)P 패턴을 사용하여 구현한다면, 실제 처리 부분을 조금 더 깔끔하게 구현 가능한 정도의 선에서 사용해야 될 것으로 보입니다.
  • 비동기/동기 처리는 UniTask를 사용합시다.
  • MessageBroker의 경우도 VContainer의 MessagePipe로 대체 가능합니다.
  • 이벤트의 상위호환 라이브러리 정도로 사용하는게 정신 건강에 좋지 않을까 싶습니다.

UniRx와 async/await와 UniTask

조금 전부터 몇 번이나 async/await 다루고 있습니다만, 이쪽은 간단하게 "편리하게 된 코루틴" 이라고 하는 인식으로 일단은 문제 없습니다. async/await 자체는 비동기 처리의 대기 처리를 간소하게 쓸 수 있도록 하기 위한, C#의 언어 기능입니다. (구조는 전혀 다르지만) async/await는 외형으로서는 Unity의 코루틴과 매우 비슷합니다.

UniRx에서도 코루틴과의 조합은 편리했지만, async/await에서도 UniRx와 조합하면 그 편리성이 높아집니다. 특히 UniTask라는 라이브러리가 강력하며, 여기를 도입함으로써 Unity에서 async/await의 취급을 대폭 강화할 수 있습니다. (async/await와 UniTask는 반드시 세트로 다루고 싶을 정도로 강력합니다)

여기서 오해하고 싶지 않지만, "async/await이 있기 때문에 UniRx는 불필요해졌다" 는 것은 아닙니다. UniRx는 UniRx에서 사용할 수 있는 경우도 아직도 있고, async/await또한 편리하게 사용할 수 있는 경우도 다수 있습니다. 옛날에는 UniRx 밖에 선택사항이 없었지만, 현대에 있어서는 UniRx를 사용하지 않아도 보다 간단하게 구현할 수 있는 수법이 늘어났다고 하는 것입니다.

UniRx가 편리한 장소, async/await가 편리한 장소

UniRx는 결코 다목적이 아닙니다. 상황에 따라서는 async/await(혹은, 완전히 다른 도구)를 사용하는 편이 깨끗하게 쓸 수 있는 경우도 많이 있습니다.

그럼 UniRx async/await를 어떻게 구사할까요, 기본적으로는 다음 구분으로 문제 없습니다.

  • UniRx가 편리하게 사용할 수 있는 경우

    • 불특정 다수에 여러 번 이벤트 메시지를 전달하는 경우
    • 특정 처리의 흐름(시퀀스)을 여러 번 반복 실행하는 경우
    • 종속성을 역전하려는 경우
    • Push형으로 구동할 필요가 있는 경우
  • async/await(및 UniTask조합)을 편리하게 사용할 수 있는 경우

    • "한 번만 실행되는 처리"를 기다리는 경우
    • 절차적 (iffor문)으로 처리를 작성하고 싶은 경우
    • 처리의 동작이 Pull형으로 끝나는 경우

UniRx는 '반복 실행'에 특화되어 있습니다. 그 때문에 "몇번인가 발행되는 이벤트를 처리한다" "Update()의 루프 대신에 사용한다"라고 하는 용도에 매치하고 있습니다.

한편으로 async/await"뭔가의 처리를 1회만 기다린다"에 특화하고 있습니다. 그 때문에 "초기화 처리가 끝나는 것을 기다린다" "외부와의 통신을 기다린다"라고 하는 경우는 async/await에서 쓰는 편이 스마트하게 쓸 수 있습니다. (단발의 처리를 UniRx로 쓰는 것도 물론 할 수 있지만, 중복이 되기 쉽다)

2. 실제 UniRx 애플리케이션

그러면 현대에서 UniRx를 어떤 경에서 사용하는지 소개합니다. 일부는 예전에는 UniRx를 사용하기도 했지만, 현대에서는 다른 방법으로 쓰는 것이 좋다는 것도 있습니다.

A. 이벤트 통지에 사용

이벤트란, "무언가의 조건을 만족했을 때, 그 때의 정보를 통지해 다른 장소에서 처리를 실행한다"라고 하는 구조를 가리킵니다. 이벤트를 이용하면 "조건을 판정하는 부분"과 "실제로 처리를 실시하는 부분"을 분리해 구현할 수 있게 됩니다.

Event

이벤트의 사용법으로서는 다음의 패턴을 생각할 수 있습니다.

  • 비 정기적으로 여러 번 반복하는 처리를 다루기 쉽다.
  • 실제의 처리 부분이 복수 있을 때, 그 조건 판정의 부분을 한 곳에서 처리한다
  • 컴포넌트간의 종속성 구성

"비 정기적으로 여러 번 반복하는 처리를 다루기 쉽다"는 Unity에서는 OnTriggerEnter등의 이벤트를 예로 들 수 있습니다. "언제 일어날지 모르지만, 발생했을 때는 즉시 대응한 처리를 실행하고 싶다"라고 하는 때에 이벤트의 개념을 사용할 수 있습니다.

Event

"실제의 처리 부분이 복수 있을 때, 그 조건 판정의 부분을 한 곳에서 처리한다"는, 예를 들면 "Input"을 들 수 있습니다. 게임을 하고 있는 사람(플레이어)으로부터의 Input을 받아들이고 캐릭터를 조작하게 됩니다. 이 때 아무것도 생각하지 않고 어리석게 구현하면 다양한 컴포넌트와 if(Input.GetKeyDown("Attack"))같은 처리가 흩어져 버립니다. 이러한 문제도 이벤트 개념을 사용하여 스마트하게 구현할 수 있습니다.

UniRx와 이벤트

우선 "Input을 판정해 그것을 Observable로 변환하는 컴퍼넌트"를 생각합니다.

using UniRx;
using UnityEngine;

namespace Events
{
public sealed class InputEventProvider : MonoBehaviour
{
/// <summary>
/// 공격 버튼 입력
/// </summary>
public IReadOnlyReactiveProperty<bool> Attack => _attack;

/// <summary>
/// 이동 방향 입력
/// </summary>
public IReadOnlyReactiveProperty<Vector3> MoveDirection => _moveDirection;

/// <summary>
/// 점프 입력
/// </summary>
public IReadOnlyReactiveProperty<bool> Jump => _jump;

// 구현
private readonly ReactiveProperty<bool> _attack = new BoolReactiveProperty();
private readonly ReactiveProperty<bool> _jump = new BoolReactiveProperty();
private readonly ReactiveProperty<Vector3> _moveDirection = new ReactiveProperty<Vector3>();

private void Start()
{
// Destroy시 Dispose()
_attack.AddTo(this);
_jump.AddTo(this);
_moveDirection.AddTo(this);
}

private void Update()
{
// 다양한 입력을 ReactiveProperty에 반영
_jump.Value = Input.GetButton("Jump");
_attack.Value = Input.GetButton("Attack");
_moveDirection.Value = new Vector3(
x:Input.GetAxis("Horizontal"),
y:0,
z:Input.GetAxis("Vertical"));
}
}
}

이 컴퍼넌트를 준비하면, 실제로 입력 이벤트를 사용해 처리를 실시하는 컴퍼넌트로부터 이것을 참조시킵니다. 이제 "UniRx를 사용하여 입력 이벤트를 처리"할 수있었습니다.

using System;
using UniRx;
using UnityEngine;

namespace Events
{
/// <summary>
/// 예: Input을 보고 이동
/// </summary>
public class PlayerMove : MonoBehaviour
{
[SerializeField] private float _moveSpeed = 1.0f;
[SerializeField] private InputEventProvider _inputEventProvider;

private CharacterController _characterController;

private void Start()
{
_characterController = GetComponent<CharacterController>();

// 점프
// 점프 버튼 입력 이벤트 결정
_inputEventProvider.Jump
// 버튼을 눌렀을 때,
.Where(x => x)
// 접지 중이며,
.Where(_ => _characterController.isGrounded)
// 마지막으로 점프한 지 1초 이상 경과하면,
.ThrottleFirst(TimeSpan.FromSeconds(1))
.Subscribe(_ =>
{
// 점프 처리 수행
Jump();
});

// 이동 처리
_inputEventProvider
.MoveDirection
// 일정값 이상 입력하면
.Where(x=>x.magnitude > 0.5f)
.Subscribe(x =>
{
// 그쪽으로 이동
_characterController.Move(x.normalized * _moveSpeed);
});
}

private void Jump()
{
// 점프 처리(생략)
}
}
}

Input

보충: "Pub/Sub" 개념

이벤트 처리의 일종으로서, "Pub/Sub"라고 하는 것이 있습니다.

일반적인 이벤트 처리에 있어서는, 일반적으로는 "누구로부터 메세지가 보내 오는지"를 구독측이 어느 정도는 의식할 필요가 있습니다. 그것을 Pub/Sub 있어서는 "이벤트 메세지 그 자체"에 주목해, "누구로부터 보내져 왔는지는 신경쓰지 않는다"라고 하는 모델이 되고 있습니다. (같이 송신측도 "누가 수신하고 있는지는 신경쓰지 않는다"라고 하는 형태가 됩니다)

Pub/Sub

UML Source Code

@startuml
left to right direction

interface IPublisher
interface ISubscriber

note top of Publisher_A : 송신측은 수신측이 누군지 모른다
note top of Subscriver_A : 수신측은 송신측이 누군지 모른다

Publisher_A --> IPublisher
Publisher_B --> IPublisher

IPublisher ..> ISubscriber : Message

note "Pub/Sub는 본체는 숨기고\n보이지 않는 어딘가를 통해\n메시지를 전송한다." as N2
IPublisher .. N2
N2 .. ISubscriber


ISubscriber <-- Subscriver_A
ISubscriber <-- Subscriver_B

@enduml

Pub/Sub를 사용하면 송신자와 수신자를 더 느슨하게 결합할 수 있습니다. 컴퍼넌트간의 참조 관계나 의존관계를 정리해 휘두르는 일 없이, 보다 데이터 플로우를 중심으로 한 구현을 실시하는 것이 가능해집니다. 그러나 다른 한편으로는 올바르게 메시지를 관리 할 수 ​​없으면 스파게티 코드가 가속된다는 문제점도 Pub/Sub있습니다. 초보자에게 추천할 수 있는 기능은 아닙니다만, 이런 것도 있으면 머리의 한쪽 구석에서 두면 어느 것이 도움이 될 때가 올 것입니다.

이제 이것 Pub/Sub이지만 구현하는 방법에는 여러 가지가 있습니다.

  • UniRx의 "MessageBroker"라는 기능 사용
  • MessagePipe 라는 라이브러리 사용

Pub/Sub을 가볍게 시도하고 싶다면 UniRx MessageBroker를 쉽게 사용할 수 추천합니다. 거기에서 한층 더 밟아, "DI와 조합해 사용하고 싶다" "서버 통신을 얽힌 Pub/Sub것을 실시하고 싶다"라고 하는 경우는 MessagePipe를 사용해 보면 좋을 것입니다.

B. 비동기 처리에 사용

앞에서 결론에서 언급하면 ​​비동기 처리에 UniRx를 사용하는 것은 더 이상 권장 하지 않습니다. 과거 2017년 이전의 Unity에서는 비동기 처리의 선택사항으로서 괜찮은 것이 UniRx 정도밖에 없었습니다. 그러나 현대에서는 async/await나 UniTask의 등장에 의해, 굳이 UniRx를 사용해 비동기 처리를 취급할 필요성이 없어졌습니다.

(원래 「비동기 처리」란 무엇을 가리키는지입니다만, 이쪽은 말하면 길어지기 때문에 다른 기사에서 다시 투고 예정입니다)

추가

"비동기 처리에 UniRx를 사용하는 것은 비추천"이지만 일부 Operator가 매우 편리합니다. 특히 OnErrorRetry는 에러 발생시 지정 횟수까지 재 시도를 해주는 것입니다. 이런 Operator를 async/await 함께 try-catch 사용하면 상당히 복잡해지기 때문에, async/await와 함께 Observable을 같이 사용하는 것도 어려울 수 있지만 존재합니다.

단, 어리석게 ToObservable().OnErrorRetry()만 해도 잘 움직이지 않고, Observable.Defer()와 병용할 필요가 있거나 합니다. 이 함수들은 상당히 까다롭기 때문에, 왜 Observable.Defer() 필요한지 모르는 사람은, 이 테크닉은 사용하지 않는 편이 안전할지도 모릅니다.

private async UniTaskVoid SampleAsync(string uri, CancellationToken token)
{
var result =
// Observable.Defer로 감싸서 Retry 발화시 GetAsync를 다시 실행하도록 합니다.
await Observable.Defer(() => GetAsync(uri, token).ToObservable())
// 에러 발생시 1초 기다린 후 총 3회까지 시도
.OnErrorRetry((UnityWebRequestException ex) => Debug.LogException(ex), retryCount: 3, TimeSpan.FromSeconds(1));

Debug.Log(result);
}

private async UniTask<string> GetAsync(string uri, CancellationToken token)
{
using (var uwr = UnityWebRequest.Get(uri))
{
await uwr.SendWebRequest().WithCancellation(token);
return uwr.downloadHandler.text;
}
}

예: AsyncOperation

Unity에서 등장하는 비동기 처리로 취급할 필요가 있는 객체 중:AsyncOperation가 있습니다. 이것은 UnityAPI에서 비동기 처리를 호출할 때 반환되는 객체입니다.

AsyncOperation한 객체를 돌려주는 Unity의 API의 예】

  • UnityWebRequest.SendWebRequest()
  • SceneManager.LoadSceneAsync()
  • AssetBundle.LoadAssetAsync

AsyncOperation는 본래라면 코루틴으로 처리하는 객체입니다만, UniTask를 도입하고 있는 경우는 async/await로 기술하는 것이 가능합니다.

// 텍스처 다운로드
public async UniTask<Texture> FetchTextureAsync(string uri, CancellationToken token)
{
using (var uwr = UnityWebRequestTexture.GetTexture(uri))
{
await uwr.SendWebRequest().WithCancellation(token);
return ((DownloadHandlerTexture) uwr.downloadHandler).texture;
}
}

(이하 비추천)

한때, UniRx 정도 괜찮은 비동기 처리의 핸들링 방법이 없었던 시대는 다음과 같은 쓰기를 하고 있었습니다. async/await와 비교해 보면, UniRx 쪽이 압도적으로 중복코드가 많고 복잡합니다.

    /// <summary>
/// 텍스처 다운로드 Observable
/// </summary>
public IObservable<Texture> FetchTextureObservable(string uri)
{
// 코루틴을 Observable로 변환
return Observable.FromCoroutine<Texture>(observer =>
FetchTextureCoroutine(uri, observer));
}

/// <summary>
/// 통신하는 코루틴
/// </summary>
private IEnumerator FetchTextureCoroutine(
string uri,
IObserver<Texture> observer)
{
using (var uwr = UnityWebRequestTexture.GetTexture(uri))
{
yield return uwr.SendWebRequest();

if (uwr.result != UnityWebRequest.Result.Success)
{
observer.OnError(new Exception(uwr.error));
}
else
{
var result = ((DownloadHandlerTexture) uwr.downloadHandler).texture;
observer.OnNext(result);
observer.OnCompleted();
}
}
}

C.Model-View-(Reactive)Presenter 패턴에 사용

Model-View-(Reactive)Presenter, 통칭 MV(R)P패턴은 Unity에서 주로 UI 구현에서 사용되는 경우가 많은 패턴입니다. View와 Model 2개의 오브젝트를 UniRx를 이용해 연결하는 구현 패턴이 됩니다.

이쪽은 다른 기사에서 해설하고 있습니다.

D.MonoBehaviour의 로직 작성

UniRx에 존재하는 UpdateAsObservable()Observable.EveryUpdate() 애용하고 있는 분은 많을 것입니다. 여기에 대해서는 UniRx를 그대로 사용해도 되지만, 경우에 따라서는 async/await쪽이 간단하게 구현 가능한 경우도 있다고 기억해 두면 좋을 것입니다.

간단한 로직을 UniRx로 작성

예로서 다음 로직을 생각해 봅시다.

조건이 충족되면 처리를 실행한 후 몇 초 동안 쿨타임에 들어갑니다. (공격을 내면 그 후 몇 초간은 다시 공격을 할 수 없다, 같다)

이것을 UniRx로 작성하면 다음과 같습니다.

using System;
using UniRx;
using UniRx.Triggers;
using UnityEngine;

namespace Samples
{
public sealed class SimpleLogicUniRx : MonoBehaviour
{
private void Start()
{
// 매 프레임 실행
this.UpdateAsObservable()
// 공격 버튼을 누르면
.Where(_ => Input.GetButtonDown("Attack"))
// 처리를 1회 실행시킨 후, 1초간 쿨타임
.ThrottleFirst(TimeSpan.FromSeconds(1))
.Subscribe(_ => Action())
.AddTo(this);
}

/// <summary>
/// 정기적으로 실행되는 처리
/// </summary>
private void Action()
{
// 공격
Debug.Log("Action!");
}
}
}

이러한 「오퍼레이터를 조합하는 것만으로 구현할 수 있는 처리」에 대해서는 UniRx를 사용해 써도 문제 없습니다.

UniRx로 작성하기 어려운 경우

UniRx의 장점은 「풍부한 오퍼레이터를 사용할 수 있다」입니다만, 반대로 단점으로서 오퍼레이터의 범위 외의 처리는 굉장히 구현하기 어려워 집니다.

조금 전의 예의 「조건을 만족하면 뭔가 처리를 실행해 그 후 몇 초간 쿨타임에 들어간다」를 조금 확장해, 다음과 같은 처리를 생각해 봅시다.

  • 조건 A를 만족하면 처리 X를 호출하고 N 초 동안 쿨타임에 들어갑니다.
  • 조건 B를 만족하면 처리 Y를 호출하고 M 초 동안 쿨타임에 들어갑니다.
  • 처리 X와 Y는 각각 배타적이며, 서로의 쿨타임 중에는 서로의 처리가 차단된다.

알기 쉽게 바꿔 말한다면, "강 공격을 내면 쿨타임이 길다. 약공격을 내면 쿨타임이 짧다. 쿨타임 중에는 공격을 일절 할 수 없다"같은 패턴입니다.

자, 이것을 UniRx만으로 작성하려고하면 어떻게 될까요? 오퍼레이터의 단순한 연결만으로는 구현할 수 없고, 상당히 얽힌 코드로 구현 코드가 나올 것 같습니다. 이러한 도중에 조건 분기가 들어가거나 조건에 따라 처리 내용이 크게 바뀌는 것을 UniRx는 매우 구현하기 힘듭니다.

이러한 경우는 UniRx를 사용하지 않고 async/await(또는 코루틴)으로 써 버리는 쪽이 결과적으로 깨끗하게 구현할 수 있습니다.

using System.Collections;
using UnityEngine;

namespace Samples
{
public sealed class ComplexLogicCoroutine : MonoBehaviour
{
private void Start()
{
StartCoroutine(LogicLoop());
}

private IEnumerator LogicLoop()
{
// Destroy 될 때까지 무한 루프
while (true)
{
if (Input.GetButtonDown("AttackA"))
{
// 입력 A가 실행되면 처리 X를 호출하여 1초 대기
ActionX();
yield return new WaitForSeconds(1);
}
else if (Input.GetButtonDown("AttackB"))
{
// 입력 B가 실행되면 처리 Y를 호출하여 2초 대기
ActionY();
yield return new WaitForSeconds(2);
}
else
{
// 입력이 없으면 1프레임 대기
yield return null;
}
}
}

private void ActionX()
{
Debug.Log("do X!");
}

private void ActionY()
{
Debug.Log("do Y!");
}
}
}
using System;
using System.Threading;
using Cysharp.Threading.Tasks;
using UnityEngine;

namespace Samples
{
public sealed class ComplexLogicUniTask : MonoBehaviour
{
private void Start()
{
// CancellationToken생성
var token = this.GetCancellationTokenOnDestroy();

// 루프 시작
LogicLoopAsync(token).Forget();
}

private async UniTaskVoid LogicLoopAsync(CancellationToken ct)
{
// Destroy 될 때까지 무한 루프
while (!ct.IsCancellationRequested)
{
if (Input.GetButtonDown("AttackA"))
{
// 입력 A가 실행되면 처리 X를 호출하여 1초 대기
ActionX();
await UniTask.Delay(TimeSpan.FromSeconds(1), cancellationToken: ct);
}
else if (Input.GetButtonDown("AttackB"))
{
// 입력 B가 실행되면 처리 Y를 호출하여 2초 대기
ActionY();
await UniTask.Delay(TimeSpan.FromSeconds(2), cancellationToken: ct);
}
else
{
// 입력이 없으면 1프레임 대기
await UniTask.Yield();
}
}
}

private void ActionX()
{
Debug.Log("do X!");
}

private void ActionY()
{
Debug.Log("do Y!");
}
}
}

역주

  • GetCancellationTokenOnDestroy은 MonoBehaviour의 라이프 사이클과 동일하게 CancellationToken이 생성된다.

MonoBehaviour의 로직 작성 요약

  • Update() FixedUpdate()에 관련된 로직은 UniRx로 기술할 수 있다
  • 다만 UniRx를 사용하는 경우는 기존의 오퍼레이터로 구현할 수 있는 범위 내에 두어 둔다
  • 조금이라도 UniRx로 쓸 수 없다고 느끼면 곧바로 포기하고 코루틴이나 async/await에 다시 쓰는 편이 최종적으로 읽기 쉬워진다

E.Update()와 같은 범위 분리

UniRx의 사용법으로서 "Update()등의 스코프를 분리한다"가 있습니다.

  • Update()와 FixedUpdate() 등의 처리를 문맥 별로 분리한다
  • Update()나 FixedUpdate() 실행 시작 타이밍 조정

이 경우 UniRx를 사용할 수 있습니다.

예: Update()를 문맥별로 분리

예를 들면 다음과 같이, Update()에 복수의 처리를 하는 코드가 있었다고 합니다.

using UnityEngine;

namespace Samples
{
public sealed class SamplePlayer : MonoBehaviour
{
// 여기에 많은 필드 변수가 정의
private void Update()
{
// 공격 처리에 따른 처
CheckAttack();

// Player의 위치에 따른 처리
CheckPosition();

// Player의 체력에 따른 처리
CheckHealth();

// 움직임 처리
Move();
}

// ↓ 메서드가 줄지어 정의되어있다가 가정한다.

/*
* 생략
*/
}
}

그런데, 이 Update()문을 보고 느끼는 것은 없을까요. "이 처리의 순서는 바꿔도 문제 없을까?", "어떤 처리와 어떤 처리는 연관된 처리가 있는가?" 라는 의문이 나올 수 있습니다.

이러한 처리 순서에 따라 제대로 동작하지 않는 메서드가 있을 수 있고, 처리 순서가 무방한 메서드가 있을 수 있습니다. 이러한 부분이 주석에 써 있을지도 모르고, 전혀 주석이 없는 코드일지도 모릅니다. Update()라는 하나의 메소드에 나란히 쓰고 있는 이상은, 항상 이러한 것을 의식해 코드를 작성/읽어야 합니다.

이러한 문제는 UniRx를 사용하면 다소 개선할 수 있습니다.

using System;
using UniRx;
using UniRx.Triggers;
using UnityEngine;

namespace Samples
{
public sealed class SamplePlayer : MonoBehaviour
{
private void Start()
{
// Player의 위치에 따른 처
this.UpdateAsObservable()
.Subscribe(_ => CheckPosition())
.AddTo(this);

// Player의 체력에 따른 처리
this.UpdateAsObservable()
.Subscribe(_ => CheckHealth())
.AddTo(this);

// 공격 처리 확인 및 이동 처리
this.UpdateAsObservable()
.Subscribe(_ =>
{
CheckAttack();
Move();
})
.AddTo(this);
}

// ↓ 메서드가 줄지어 있다고 가정한다.

/*
* 생략
*/
}
}

이와 같이, 각 처리마다 따로 따로 Observable 저장하는 것으로 처리 끼리의 스코프를 명확하게 구분 할 수 있었습니다. 또 처리마다 Observable가 분리되어 있기 때문에, 일부 처리만 오퍼레이터를 추가하거나 조정도 쉬워집니다.

예: 일부 처리만 실행 시작 타이밍을 어긋나기

Update()를 각 처리마다 Observable 나누어 버리는 이점으로서, "실행 개시 타이밍을 조정하기 쉽다"라고 하는 것이 있습니다.

예를 들어 특정의 메소드가 불려 갈 때까지는 Update()의 처리의 일부를 스킵 해 두고 싶다고 하는 경우입니다. (적 캐릭터가 화면에 비칠 때까지 이동 처리를 멈추고 싶은 등)

using UniRx;
using UniRx.Triggers;
using UnityEngine;

namespace Samples
{
public sealed class SampleEnemy : MonoBehaviour
{
private void Start()
{
// 화면에 비치면 초기화 처리를 호출합니다.
this.OnBecameVisibleAsObservable()
.Take(1)
.Subscribe(_ => Initialize())
.AddTo(this);
}

/// <summary>
/// 초기화 처리
/// </summary>
private void Initialize()
{
// 매 프레임 Move()를 호출하도록 설정
this.UpdateAsObservable()
.Subscribe(_ => Move())
.AddTo(this);
}

/// <summary>
/// 이동 처리
/// </summary>
private void Move()
{
// 이동 처리
}
}
}

Update()와 같은 범위를 분리합니다.

이것은 UniRx가 처리를 모두 Observable라고 하는 객체에 감싸는 성질을 이용한, 약간의 테크닉입니다. 기억해두면 사용하기 편리하기 때문에 개인적으로는 가끔 사용하는 기술 이기도 합니다.

덧붙여 이 기술을 async/await + UniTask로 사용하는 것도 가능합니다. 다만 UniRx와 비교해 이쪽은 기술량이 증가해 코드가 다소 복잡해 집니다. 그 때문에 이 용도에 있어서는 UniRx의 사용이 더 편리한 것으로 보입니다.

private void Start()
{
var token = this.GetCancellationTokenOnDestroy();

UniTask.Void(async () =>
{
while (!token.IsCancellationRequested)
{
CheckPosition();
await UniTask.Yield();
}
});

UniTask.Void(async () =>
{
while (!token.IsCancellationRequested)
{
CheckHealth();
await UniTask.Yield();
}
});

UniTask.Void(async () =>
{
while (!token.IsCancellationRequested)
{
CheckAttack();
Move();
await UniTask.Yield();
}
});
}

F. 종속성을 정리하는 데 사용

UniRx는 종속성을 구성하는 데 사용할 수 있습니다. (UniRx의 기능보다는 Observer 패턴의 성질 그 자체)

방금 설명한 「이벤트 처리」와 같은 이야기입니다. (시점이 의존 관계로 정리가 되어 있을 뿐, 하고 있는 것은 이벤트 처리 그 자체)

예: Player 및 PlayerManager

예를 들어, 다음과 같은 경우를 생각해 봅시다.

  • PlayerManager: Player 생성 및 수명주기 관리
  • Player: 자신이 죽은 것을 PlayerManager 알려준다.

자, 이것을 생각 없이 그대로 구현하면 다음과 같은 코드가 될 것입니다.

using UnityEngine;

namespace Samples2
{
public class PlayerManager : MonoBehaviour
{
// Player의 Prefab
[SerializeField] private Player _playerPrefab;

// 지금 존재하는 플레이어의 실체
private Player _currentPlayer;

private void Start()
{
CreatePlayer();
}

public void OnPlayerDead()
{
// 플레이어가 죽었을 때의 처리가 여기에
_currentPlayer = null;

// 새로운 플레이어 생성
CreatePlayer();
}

private void CreatePlayer()
{
// 플레이어 생성
_currentPlayer = Instantiate(_playerPrefab);

// Player에게 Manager를 가르치기
_currentPlayer.Initialize(this);
}
}
}
using UnityEngine;

namespace Samples2
{
public class Player : MonoBehaviour
{
private PlayerManager _playerManager;

public void Initialize(PlayerManager playerManager)
{
// 초기화 시 Manager 유지
_playerManager = playerManager;
}

private void OnDestroy()
{
// 이번에는 OnDestroy되면 '사망'이라는 취급으로
_playerManager.OnPlayerDead();
}
}
}

이 코드에는 하나의 큰 문제가 있습니다. 그것은 PlayerPlayerManager가 상호 참조하는 것입니다.

Player And PlayerManager

상호 참조는 나중에 스파게티 코드로 발전 할 위험이 매우 높습니다. 따라서 가능한 한 상호 참조를 제거해야합니다.

그래서 이것을 UniRx (또는 UniTask)를 사용하여 정리해 보겠습니다.

UniRx를 사용하여 종속성 구성

UniRx Observable를 이용하면 참조 관계를 일방통행으로 정리할 수 있습니다.

using UniRx;
using UnityEngine;

namespace Samples2
{
public class PlayerManager : MonoBehaviour
{
// Player의Prefab
[SerializeField] private Player _playerPrefab;

// 지금 존재하는 플레이어의 실체
private Player _currentPlayer;

private void Start()
{
CreatePlayer();
}

private void OnPlayerDead()
{
// 플레이어가 죽었을 때의 처리가 여기에
_currentPlayer = null;

// 새로운 플레이어 생성
CreatePlayer();
}

private void CreatePlayer()
{
// 플레이어 생성
_currentPlayer = Instantiate(_playerPrefab);

// 생성된 플레이어를 모니터링하고
// 사망 이벤트가 오면 OnPlayerDead 실행
_currentPlayer
.PlayerDeadAsync
.Subscribe(_ => OnPlayerDead())
.AddTo(this);
}
}
}
using System;
using UniRx;
using UnityEngine;

namespace Samples2
{
public class Player : MonoBehaviour
{
/// <summary>
/// 플레이어가 사망한 통지를 발행하는 Observable
/// </summary>
public IObservable<Unit> PlayerDeadAsync => _playerDeadSubject;

// 1회만 통지를 발행하는 경우에는 AsyncSubject가 편리
private readonly AsyncSubject<Unit> _playerDeadSubject = new AsyncSubject<Unit>();

private void OnDestroy()
{
// 플레이어가 사망한 통지 발행
_playerDeadSubject.OnNext(Unit.Default);
_playerDeadSubject.OnCompleted();

_playerDeadSubject.Dispose();
}
}
}

Player And PlayerManager2

UniRx Observable를 사용하여 알림 흐름을 정리하고 참조 관계를 일방통행으로 만들 수 있습니다. 원래 Observable는 "불특정 다수에게 자신의 상태를 감시하게 한다"라는 용도를 위한 기능입니다. 그러므로 이러한 "뭔가 상태가 변화했을 때 그것을 상대에게 전달한다"는 경우에서는 Observable 강력하게 작용합니다.

응용 프로그램 : 상태 알림

Observable의 "불특정 다수에게 자신의 상태를 감시시킨다"라고 하는 용도를 좀 더 생각해 보겠습니다.

예를 들어 액션 게임에서 "뭔가 이벤트가 일어났을 때 그에 따라 복수의 처리를 동시에 실행시킨다"라는 패턴은 자주 존재합니다. 이러한 처리를 구현하는 경우, 이벤트 발행측이 통지처를 모두 파악하는 코드는 매우 파악하기 어렵게 됩니다.

예로서 다음과 같은 구현이 있다고 합니다.

  • 플레이어가 데미지를 받으면 다음 처리가 실행됩니다.
    • 체력 감소
    • 넉백 효과
    • 데미지 애니메이션 재생
    • 파티클 이펙트가 나온다
    • 효과음 재생
    • 디버깅 할 때만 UI에 숫자를 표시하고 싶습니다.

이것을 "데미지를 받았을 때에 하나하나 통지처의 메소드를 호출해 간다"라고 하는 구현으로 하면 이렇게 됩니다.

Player Core

UML Source Code

@startuml
package PlayerComponents {
class PlayerCore {
}
class PlayerHealth {
+OnPlayerDamaged(int value)
}
class PlayerMove {
+OnPlayerDamaged(int value)
}
class PlayerAnimation {
+OnPlayerDamaged(int value)
}
class PlayerEffectEmmiter {
+OnPlayerDamaged(int value)
}
class PlayerSfx {
+OnPlayerDamaged(int value)
}
}

package Debug {
class DebugGUI {
+OnPlayerDamaged(int value)
}
}

PlayerCore-->PlayerHealth
PlayerCore-->PlayerMove
PlayerCore-->PlayerAnimation
PlayerCore-->PlayerEffectEmmiter
PlayerCore-->PlayerSfx
PlayerCore->DebugGUI

note top of PlayerCore : Player의 피해를 관리하는 클래스
note bottom of PlayerHealth : 체력 컴포넌트
note bottom of PlayerMove : 이동 컴포넌트
note bottom of PlayerAnimation : 애니메이션 컴포넌트
note bottom of PlayerEffectEmmiter : 파티클 컴포넌트
note bottom of PlayerSfx : 사운드 컴포넌트
note bottom of DebugGUI : Debug 정보 표시
@enduml

이 구현의 약점은 "PlayerCore 모두를 관리하지 않으면 안된다"라고 하는 점에 있습니다. 통지처를 모든 PlayerCore 것이 알고 있는 상태로 해, 게다가 상황에 따라서 통지한다/안한다 판단을 PlayerCore하지 않으면 안됩니다. 이에 대한 PlayerCore 책임이 커지고, 점점 복잡한 코드로 성장해 버립니다.


이러한 문제는 Observable를 사용하여 해결할 수 있습니다.

PlayerCoreObservable 정의하고 각 컴포넌트가 필요에 따라 Subscribe 하는 형식으로 해 줍니다. 이렇게 하면 PlayerCore 책임이 크게 줄어들고 "이벤트 알림을 처리하는 방법"의 책임이 각 구성 요소에 분산됩니다.

Player Observable

UniTask를 사용하여 종속성 구성

UniTaskasync/await를 사용하여 종속성을 구성할 수 있습니다. 단, UniRx와 달리 UniTask는 "이벤트 통지 횟수가 1회에 한정한다" 경우에만 이용 가능합니다.

using Cysharp.Threading.Tasks;
using UnityEngine;

namespace Samples2
{
public class Player : MonoBehaviour
{
/// <summary>
/// 플레이어가 사망하면 완료되는 UniTask
/// </summary>
public UniTask PlayerDeadAsync => _playerDeadUtc.Task;

private readonly UniTaskCompletionSource _playerDeadUtc = new UniTaskCompletionSource();

private void OnDestroy()
{
// 플레이어가 사망하면 UniTask 완료
_playerDeadUtc.TrySetResult();
}
}
}
using System.Threading;
using Cysharp.Threading.Tasks;
using UnityEngine;

namespace Samples2
{
public class PlayerManager : MonoBehaviour
{
// Player의Prefab
[SerializeField] private Player _playerPrefab;

// 지금 존재하는 플레이어의 실체
private Player _currentPlayer;

private void Start()
{
SetupPlayerAsync(this.GetCancellationTokenOnDestroy()).Forget();
}

private void OnPlayerDead()
{
// 플레이어가 죽었을 때의 처리가 여기에
_currentPlayer = null;

// 새로운 플레이어 생성
SetupPlayerAsync(this.GetCancellationTokenOnDestroy()).Forget();
}

private async UniTaskVoid SetupPlayerAsync(CancellationToken token)
{
// 플레이어 생성
_currentPlayer = Instantiate(_playerPrefab);

// 생성된 플레이어를 모니터링하고
// 사망하면 OnPlayerDead 실행
await _currentPlayer.PlayerDeadAsync;
token.ThrowIfCancellationRequested();

OnPlayerDead();
}
}
}

종속성을 정리하는 데 사용되는 요약

UniRx를 사용하면 종속성을 정리하고 이벤트 흐름을 개선할 수 있습니다. 또 통지 회수가 1회에 한한 경우에 있어서는 "UniRx"나 "UniTask+async/await"의 어느 쪽에서도 기술하는 것도 가능합니다.

덧붙여 개인적으로는, 통지 회수가 1회만이라면 UniTask+async/await, 몇번이나 통지한다면 Observable로 구분해서 사용 하고 있습니다. Observable라고 하는 형태만을 보았을 때에, "이것은 Observable 몇번 이벤트를 발행하는 것일까"를 모릅니다. 그렇다면 "UniTask 그렇다면 절대 많아도 1회 밖에 발행되지 않는다", "Observable 그렇다면 복수회 발행될 것이다" 라고 나누는 것이 편하기 때문입니다. (상대측의 구현 내용을 상상해 쓰는, 같은 불필요한 일 하지 않아도 된다)

G. 스크립트 실행 순서를 조정하는 데 사용

Unity에는 Script Execution Order 라는 스크립트 실행 순서를 조정하는 메커니즘이 있습니다. Script Execution Order은 마지막 보루로 남겨 두어야 할 정도로 안이하게 만져서는 안되는 기능입니다.

그렇다면 실행 순서를 관리하고 싶을 때 어떻게해야할까요? UniRx를 사용하여 이벤트에서 작동하도록 해 버리는 것입니다. 즉, "전의 컴퍼넌트가 끝나면 다음의 컴퍼넌트가 연쇄해 실행된다"라고 하는 구조로 합니다. 이렇게 하면 컴포넌트 간의 호출 순서 Update()FixedUpdate() 생각하지 않아도 됩니다.

예 : UniRx로 구성 요소 간의 타이밍 조정

예를 들어, 다음과 같은 구현을 생각해 보겠습니다.

  • PlayerInput에서 버튼 입력을 수락
  • 버튼 입력을 확인하고 PlayerMoveController 점프 처리를 수행합니다.
  • 점프 상태가 되면 PlayerAnimation 점프 애니메이션 재생

"각 컴퍼넌트가 Update()를 실행해, 조건을 만족하면 각 처리를 실행한다" 라고 하는 어리석은 구현도 가능합니다. 하지만 이 방법의 경우, 실행 순서가 어긋나서 처리가 1프레임 늦어지거나, 상태 체크를 흘리는 등의 문제가 일어날 수 있습니다.

그 때문에 그러한 것을 Update() 사용하는 코드를 멈추고 UniRx를 이용해 처리가 연쇄하는 구현 으로 해 보겠습니다.

using UniRx;
using UnityEngine;

namespace Samples3
{
/// <summary>
/// 입력 이벤트 관리
/// </summary>
public sealed class PlayerInput : MonoBehaviour
{
/// <summary>
/// 점프 버튼의 입력 상태를 나타내는 ReactiveProperty
/// </summary>
public IReadOnlyReactiveProperty<bool> JumpButton => _jumpButton;

private readonly ReactiveProperty<bool> _jumpButton = new ReactiveProperty<bool>(false);

/*
* 그 밖에도 여러 가지 이벤트가 줄지어 있지만 생략
*/

private void Start()
{
// Destroy 시에 Dispose 되도록(듯이) 한다
_jumpButton.AddTo(this);
}

private void Update()
{
// 버튼 상태를 반영하여 알림
_jumpButton.Value = Input.GetButton("Jump");
}
}
}
using System;
using UniRx;
using UniRx.Triggers;
using UnityEngine;

namespace Samples3
{
public sealed class PlayerMoveController : MonoBehaviour
{
/// <summary>
/// 접지 되었습니까?
/// </summary>
public IReadOnlyReactiveProperty<bool> IsGrounded => _isGrounded;

private readonly ReactiveProperty<bool> _isGrounded = new ReactiveProperty<bool>(false);

/// <summary>
/// 점프 이벤트
/// </summary>
public IObservable<Unit> OnJump => _jumpSubject;

private readonly Subject<Unit> _jumpSubject = new Subject<Unit>();

[SerializeField] private LayerMask _groundLayerMask;
[SerializeField] private PlayerInput _playerInput;
private Rigidbody _rigidbody;

private void Start()
{
_isGrounded.AddTo(this);
_jumpSubject.AddTo(this);
_rigidbody = GetComponent<Rigidbody>();

// 접지 상태 확인
this.FixedUpdateAsObservable()
.Subscribe(_ =>
{
// 레이를 발밑으로 날리다
_isGrounded.Value = Physics.SphereCast(origin: transform.position + Vector3.up * 0.04f,
radius: 0.02f,
direction: Vector3.down, hitInfo: out var _, maxDistance: 0.05f, _groundLayerMask);
})
.AddTo(this);


// 입력 이벤트 처리
_playerInput.JumpButton
// 점프 버튼을 누른 순간에 접지하면 실행
.Where(x => x && _isGrounded.Value)
// Input 이벤트는 Update() 타이밍이므로,
// 다음 FixedUpdate 타이밍으로 타이밍 조정
.ObserveOnMainThread(MainThreadDispatchType.FixedUpdate)
.Subscribe(_ =>
{
// 점프실행
Jump();
})
.AddTo(this);
}

private void Jump()
{
_rigidbody.AddForce(Vector3.up * 10.0f, ForceMode.VelocityChange);

// 점프 이벤트 발행
_jumpSubject.OnNext(Unit.Default);
}
}
}
using UniRx;
using UnityEngine;

namespace Samples3
{
public sealed class PlayerAnimation : MonoBehaviour
{
[SerializeField] private Animator _animator;
[SerializeField] private PlayerMoveController _moveController;

/// <summary>
/// 점프 애니메이션 재생
/// </summary>
private bool IsJumping
{
set => _animator.SetBool("Jumping", value);
}

private void Start()
{
// 점프 이벤트가 오면 점프 애니메이션 시작
_moveController.OnJump.Subscribe(_ => IsJumping = true).AddTo(this);

// 접지하면 점프 애니메이션 해제
_moveController.IsGrounded.Where(x => x).Subscribe(_ => IsJumping = false).AddTo(this);
}
}
}

UniRx를 사용하면 3개의 컴포넌트가 충돌 없이 순서대로 동작시킬 수 있게 되었습니다. 이와 같이 각 컴퍼넌트로부터 Update()와 FixedUpdate()를 최대한 배제하는 것으로, 동작 순서를 완전하게 제어할 수 있게 됩니다. (정확히 반응)

역주

  • ObserveOnMainThread(MainThreadDispatchType.FixedUpdate)을 사용하여 FixedUpdate에서 점프 처리를 실행하도록 처리하였다.

예: 초기화 순서 조정(async/await)

실행 순서의 조정 Update()와 FixedUpdate() 어떠한 것에 한정되지 않고, 초기화(Start()나 Awake())의 순서도 중요하게 되는 일도 있습니다. 이러한 상황에서도 UniRx를 사용할 수 있습니다. 그렇지만, "초기화"라고 하는 기본적으로 1회 밖에 실행되지 않는 처리에 대해서는 "async/await+UniTask"로 쓰는 편이 깨끗해집니다.

using System.Linq;
using System.Threading;
using Cysharp.Threading.Tasks;
using UnityEngine;

namespace Samples3
{
public sealed class EnemyManager : MonoBehaviour
{
[SerializeField] private Enemy _enemyPrefab;

/// <summary>
/// 적을 복수체 생성하여 초기화
/// </summary>
public async UniTask InitializeAsync(CancellationToken token)
{
// 10개 생성하고 모든 초기화가 끝날 때까지 기다린다
// (UniTask.WhenAll을 암시 적으로 호출 중)
await Enumerable.Range(0, 10).Select(x => CreateEnemyAsync(token));
}

/// <summary>
/// 적을 생성한다
/// </summary>
private async UniTask CreateEnemyAsync(CancellationToken token)
{
var enemy = Instantiate(_enemyPrefab);
// 초기화가 끝날 때까지 기다리다
await enemy.InitializedAsync;
// 초기화 중에 취소되면 중지
token.ThrowIfCancellationRequested();
}
}
}
using System.Threading;
using Cysharp.Threading.Tasks;
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;

namespace Samples3
{
/// <summary>
/// 적
/// </summary>
public sealed class Enemy : MonoBehaviour
{
/// <summary>
/// 초기화가 완료되었는지 여부
/// </summary>
public UniTask InitializedAsync => _initialTaskCompletionSource.Task;
private readonly UniTaskCompletionSource _initialTaskCompletionSource = new UniTaskCompletionSource();

private AsyncOperationHandle<GameObject> _operationHandle;

/// <summary>
/// 이 적 객체의 자식 요소 (비동기로 초기화됨)
/// </summary>
private GameObject _child;

/// <summary>
/// Start에서 초기화 처리가 실행됨
/// </summary>
private void Start()
{
var ct = this.GetCancellationTokenOnDestroy();
InitializeAsync(ct).Forget();
}

/// <summary>
/// 초기화 처리
/// </summary>
private async UniTaskVoid InitializeAsync(CancellationToken token)
{
_operationHandle = Addressables.InstantiateAsync("Child");
await _operationHandle;
_child = _operationHandle.Result;
}

private void OnDestroy()
{
Addressables.Release(_operationHandle);

// Destroy가 먼저 실행되 취소합니다.
_initialTaskCompletionSource.TrySetCanceled();
}
}
}

H. 컬렉션의 변동을 통지

컬렉션 Array과 Dictionary같은 여러 데이터를 처리하는 객체를 가리킵니다.

UniRx로 컬렉션 변동 모니터링

ReactiveCollectionUniRx 에는 약간 ReactiveDictionary의 객체가 있습니다.

  • ReactiveCollection
  • ReactiveDictionary

이러한 객체를 이용하는 것으로, "배열이나 맵의 내용이 변동했다"라는 것을 즉시 감지해 이벤트 처리를 실행하는 것이 가능해집니다.

보충:ObservableCollections

UniRx ReactiveCollection에 가까운 기능을 제공하는 라이브러리 ObservableCollections가 있습니다.

이곳은 UniRx에 의존하지 않고, 다양한 컬렉션의 감시를 할 수 있게 되는 라이브러리가 되고 있습니다. (UniRx에는 존재하지 않는, ObservableHashSet, ObservableRingBuffer 있습니다)

"UniRx를 도입하고 싶지 않다" "UniRx가 제공하는 ReactiveCollection 것보다 더 심도 있는 처리를 하고 싶다"라고 하는 경우는 이쪽의 도입도 검토하면 좋을 것입니다.

I. (이하, 생각하면 가필합니다)

요약

  • "UniRx 전용"해서 사용해야 되는 시대는 사라졌습니다.
    • 현대는 UniRx 이상에 편리한 라이브러리와 언어 기능이 갖추어져 있습니다.
    • 특히 async/await+UniTask는 매우 강력하며 UniRx 대신 사용할 수 있는 패턴이 많습니다.
  • 그러나 한편으로 UniRx의 용도가 완전히 없어진 것은 아니다.
    • 단순한 Observer 패턴의 구현 라이브러리로 사용해도 편리
    • ReactiveProperty는 특히 편리성이 높고 현역에서 사용할 수 있습니다.
  • UniRx를 너무 과신하지 말자
    • 사용 장소의 판별은 중요
    • async/await+UniTask쪽이 깨끗하게 쓸 수 있는 패턴도 있다
    • (async/await 모른다면 코루틴을 사용할 수 있습니다)

· 약 12분
karais89

개요

유니티로 게임을 개발시 좀 더 구조화된 방법으로 개발하고 싶다.

유지보수가 쉽고, 유연하며, 남들이 쉽게 알아볼 수 있는 코드로 개발하는 것이 목표이다.

학부 과정에서는 MVC 패턴에 대해 배우고, 웹 프레임워크들이 MVC 패턴 구조로 많이 개발되어 있는 것도 알고 있다. MVC 패턴의 장점은 사용자 인터페이스로부터 비즈니스 로직을 분리하여 애플리케이션의 시각적 요소나 그 이면에서 실행되는 비즈니스 로직을 서로 영향 없이 쉽게 고칠 수 있는 애플리케이션을 만들 수 있다는 것이다.

MVC에서 파생되는 다양한 패턴들 MVP, MVVM은 결국 위와 같이 역할의 분리에 초점을 맞추고 있다고 보면 되고, 세부적인 내용이 조금 다르다고 이해해도 크게 무리가 없다고 생각한다.

유니티에서의 패턴

MVC, MVP, MVVM 중 유니티에 알맞는 패턴?

안드로이드에서는 보통 MVP나 MVVM 패턴을 사용함.

MVVM은 개인적으로는 너무 복잡해지는 경향이 있는 것 같아 아예 제외시킴. (뷰와 모델의 바인딩 처리도 유니티에서는 따로 처리하기 힘든 부분이 있고, 복잡해짐)

결국 MVC나 MVP 중에 선택을 해야 되는 문제이고, 유니티에서는 MVP가 더 적합하다는 판단이 들어 MVP를 선택하기로 함. (유니티에서는 일반적으로 UI를 표현하는 컴포넌트들이 화면을 직접 그리는 역할과 액션 처리를 함께 담당한다. 이러한 특징으로 MVC패턴을 사용하여 화면을 그리는 View와 액션을 처리하는 Controller을 완전히 분리하기 어렵게 된다)

MVP 패턴이란?

MVP 패턴에 대한 자료를 찾아보면, 정확한 정의는 없고, 대략적인 공통점들은 있다.

기본적인 기조는 같지만, 세부 적인 구현 내용은 개발자의 역량에 달린 것으로 보인다.

대략적으로 MVP 패턴은 아래 특징을 가지고 있다.

Model - View - Presneter

Model

  • Data와 관련된 모든 처리를 담당한다. 비즈니스 로직 처리.
    • 비즈니스 로직은 컴퓨터 프로그램에서 실세계의 규칙에 따라 데이터를 생성·표시·저장·변경하는 부분을 일컫는다.

View

  • 사용자에게 보여지는 UI 부분 (유니티에서는 모든 렌더링 되는 Object)

Presenter

  • View에서 요청한 정보(User actions)로 Model을 가공하여(Update model) 변경된 Model정보를 받아(Model changed) View에게 전달(Update UI)해주는 부분
  • 접착제 역할

관계도

MVP

특징

  • View와 Model은 서로를 알지 못한다. (어떤 방법으로든 접근할 수 없다)
  • Presenter은 View와 Model을 알고 있다.

여기서 알고 있다는 부분에 대한 해석으로 좀 헤매였던 부분이 있었는데, 알고 있다는 부분은 해당 인스턴스를 직접적으로 조작한다로 해석해도 무방할것으로 보인다.

→ 직접 조작하지만 않으면 알고 있지 않은 것 (이벤트 방식, SendMessage 등)

MV(R)P

UniRx 플러그인을 사용하면 유니티에서 MVP 패턴을 좀 더 쉽게 구현할 수 있다. (MVP 패턴에서 구현해야 되는 이벤트 기반 코드들을 더 쉽게 사용) 2020년 10월 9일 기준 v7.10(19년 7월 1일) 까지 나와 있는 상태이고, 원래는 Reactive Programming을 유니티에서 쉽게 사용 하기 위해 만들어진 플러그인이다.

UniRx를 사용하면 MVP (MVRP) 패턴을 구현할 수 있습니다.

MVP

MVVM 대신 MVP를 사용해야하는 이유?

유니티는 UI 바인딩을 제공하지 않으며, 바인딩 레이어를 만드는 것은 복잡하며, 오버헤드가 크다.

MVP 패턴을 사용하는 Presenter는 View의 구성요소를 알고 있으며 업데이트 할 수 있다. 실제 바인딩을 하지 않지만, View를 구독(Observable)하여 바인딩 하는 것과 유사하게 동작하게 할 수 있다. (복잡하지 않고, 오버 헤드도 적게 사용 가능)

이 패턴을 Reactive Presenter라고 한다.

// Presenter는 씬의 canvas 루트에 존재.
public class ReactivePresenter : MonoBehaviour
{
// Presenter는 View를 알고 있다(인스펙터를 통해 바인딩 한다)
public Button MyButton;
public Toggle MyToggle;

// Model의 변화는 ReactiveProperty를 통해 알 수 있다.
Enemy enemy = new Enemy(1000);

void Start()
{
// Rx는 View와 Model의 사용자 이벤트를 제공한다.
MyButton.OnClickAsObservable().Subscribe(_ => enemy.CurrentHp.Value -= 99);
MyToggle.OnValueChangedAsObservable().SubscribeToInteractable(MyButton);

// Model들은 Rx를 통해 Presenter에게 자신의 변화를 알리고, Presenter은 Viw를 업데이트 한다.
enemy.CurrentHp.SubscribeToText(MyText);
enemy.IsDead.Where(isDead => isDead == true)
.Subscribe(_ =>
{
MyToggle.interactable = MyButton.interactable = false;
});
}
}

// Model. 모든 프로퍼티는 값의 변경을 알려 준다. (ReactiveProperty)
public class Enemy
{
public ReactiveProperty<long> CurrentHp { get; private set; }

public ReactiveProperty<bool> IsDead { get; private set; }

public Enemy(int initialHp)
{
// 프로퍼티 정의
CurrentHp = new ReactiveProperty<long>(initialHp);
IsDead = CurrentHp.Select(x => x <= 0).ToReactiveProperty();
}
}

View는 하나의 Scene이며, Unity의 hierarchy이다. (하나의 개체 혹은 객체?)

View는 초기화시 Unity 엔진에 의해 Presenter와 연결된다.

XxxAsObservable 메서드를 사용하면 오버 헤드없이 이벤트 신호를 간단하게 생성 할 수 있습니다. SubscribeToText 및 SubscribeToInteractable은 간단한 바인딩 처럼 사용할 수 있게 하는 helper 클래스 입니다. 이것은 단순한 도구 일 수 있지만 매우 강력합니다. Unity 환경에서 자연스럽게 느껴지며 고성능과 깨끗한 아키텍처를 제공합니다.

MV(R)P

  • V-> RP-> M-> RP-> V가 완전히 Reactive(반응적인)한 방법으로 연결되었다.

  • GUI 프로그래밍은 ObservableTrigger의 이점도 제공합니다. ObservableTrigger는 Unity 이벤트를 Observable로 변환하므로이를 사용하여 MV(R)P 패턴을 구성 할 수 있습니다. 예를 들어 ObservableEventTrigger는 uGUI 이벤트를 Observable로 변환합니다.

var eventTrigger = this.gameObject.AddComponent<ObservableEventTrigger>();
eventTrigger.OnBeginDragAsObservable()
.SelectMany(_ => eventTrigger.OnDragAsObservable(), (start, current) => UniRx.Tuple.Create(start, current))
.TakeUntil(eventTrigger.OnEndDragAsObservable())
.RepeatUntilDestroy(this)
.Subscribe(x => Debug.Log(x));

설계 방향

  • MVP 패턴을 보면서 헷갈리거나 정립되지 않는 부분은 과감히 내 방식으로 정립하고, 구현 후 문제점 발생시 개선하는 방향으로 진행.
  • 완전히 디자인 패턴을 따르지는 않을 예정 (클린 코드가 되는 대신 생산성이 저하되는 부분은 과감히 생산성을 따르는 방향)
  • 하나의 Presenter에 여러개의 Model이 존재할 수 있다.
    • 각 모델의 경우 역할별로 클래스화 작업.
  • 하나의 Presenter에 여러개의 View가 존재할 수 있다.
  • Presenter는 각 팝업, 각 오브젝트 별로 존재한다. (컴포넌트 개념으로 생각)
    • 팝업의 아이템이 존재한다면 그 아이템도 각각의 Presenter가 존재. 구조가 복잡하지 않는다면 없어도 무방.
  • 간단한 예제에서는 항상 View-Presenter-Model은 1개씩 존재 했기 때문에, 각 Presetenr 1개에 2개이상의 view와 model이 존재해도 문제 없는지에 대한 고민을 함.
  • 그리고 Model의 구현시 거의 모든 역할을 Model에서 한다고 생각하면 될것으로 보임 (Presenter는 Model의 메서드를 호출하는 정도의 역할)
    • 보통의 예제에서는 간단한 메서드 구현 정도는 Presenter에서 해주는 부분도 있지만, Model이 전부 해주는게 더 일반적인 구조인것으로 보임.

결론

  • MV(R)P를 사용한 설계 진행
  • MV(R)P 아키텍처에 대해 다양한 자료 조사를 진행하였음. Github에서 UniRx 개발자가 예시로든 방법이 가장 깔끔하고, 생산성 있게 구조화 할 수 있는 방법으로 판단 되었음.
  • MV(R)P 시행 착오 (현재로서는 잘못됬다고 생각하는 방법)
    • 인터페이스를 사용하여 Presenter의 의존성을 제거하는 방법.
    • View 컴포넌트를 따로 뺀다던가, View 자체를 여러개 둔다 던가 하는 방법.
    • Model을 하나만 두어서 제어하는 방법.
    • MV(R)P는 UI에만 적용하는게 더 좋겠다라고 생각한 부분
  • View의 경우 복잡해지는 경우 커스텀 View를 만드는 방식으로 해결 가능.

참고

· 약 21분
karais89

환경

  • macOS Catalina v10.15
  • Unity 2019.2.10f1
  • Github Desktop
  • Rider 2019.2
  • UniRx v7.1.0

원문 : https://qiita.com/toRisouP/items/c4b9c5701dd6c991b481

이 포스팅은 원문을 단순히 구글 번역을 하여 정리한 내용입니다. 일본어를 잘하시는 분은 원문을 보시는게 더 좋으실 것 같습니다.

[UniRx 입문 시리즈 목차는 이쪽]({% post_url 2019-11-17-UniRx-Getting-Started-List %})

0. 이전 복습

[지난번]({% post_url 2020-02-23-Getting-Started-4 %})에는 Update()를 어떻게 Observable로 변환하여 이용할 수 있는지 설명 하였습니다.

이번에는 한 걸음 더 나아가 코루틴과 함께 UniRx를 사용하는 방법을 소개하겠습니다.

1. 코루틴과 UniRx

Unity는 기본적으로 "코루틴"이라는 기능이 포함되어 있습니다. 이것은 본래는 이터레이터 처리를 구현하는데 사용하는 IEnumeratoryield 키워드를 활용하여 Unity의 주 스레드에서 비동기 처리 같은 것을 실현하는 기능 입니다.

(간혹 착각하고 있는 사람이 있지만, 코루틴에 실행되는 작업은 Unity의 메인 스레드에서 실행됩니다. Update()와 비슷한 처리로 실행 타이밍도 대부분 같습니다. 참고

그리고 지금까지 소개해온 UniRx는 코루틴과 병행하여 적용하면 한층 더 표현의 폭을 넓힐 수 있습니다. 왜냐하면 UniRx는 선언적으로 기술한 스트림은 if 분기할수 없거나, 스트림의 결과를 이용하여 그대로 절차적 처리에 연결하는 등의 처리가 어려웠습니다. 하지만 코루틴과 UniRx를 병행함으로써 이러한 문제를 해결 할 수 있게 됩니다. 또한 코루틴의 절차적 처리의 이점을 살리면서, UniRx의 유연한 예외 처리를 사용하는 것도 가능 합니다.

UniRx와 코루틴의 조합은 정말 편리해서 꼭 사용법을 기억하고 활용했으면 좋겠습니다.

용어 해설

  • 선언적: 부작용이 없는 함수를 메소드 체인으로 연결해 일련의 동작을 설명하는 방식
    • 장점: 필요한 처리를 차례로 연결해 쓰는 것만으로 구현되어 가독성이 높다.
    • 단점: 필요한 작업이 너무 복작하면 기존 함수만으로는 구현할 수 없는 경우가 있다.
  • 절차적: 상태 변수나 for와 if문을 사용하여 동작을 전부 설명하는 방식
    • 장점: 내 마음대로 사용할 수 있어, 어떤 처리도 가능하다.
    • 단점: 복잡한 기술이 증가하고 가독성이 낮아진다.

[역주]

  • 절차적 프로그래밍: "루틴", "서브루틴", "메소드", "함수" 등 "프로시저"를 이용한 프로그래밍 패러다임.
  • 이터레이터: 컬렉션에 대해 사용자 지정 반복을 수행, yield return 문을 사용하고 각 요소를 한 번에 하나씩 반환한다. 이터레이터는 현재 위치를 기억하고 다음 반복에서는 다음 요소를 반환한다.

2. 코루틴에서 IObservable로 변환

우선 코루틴에서 IObservable로 변환하는 방법을 소개 합니다.

코루틴을 스트림으로 변환하면 코루틴의 결과로 그대로 UniRx 오퍼레이터 체인에 연결하여 주는 것이 가능합니다.

또한 복잡한 행동을 하는 스트림을 생성 할 때는 코루틴에서 구현하고 스트림으로 변환하는 방법을 취하는 것이 UniRx 오퍼레이터 체인만으로 스트림을 구축하는 것보다 간단하게 처리되는 경우도 있습니다.

Ⅰ. 코루찐 종료 시간을 스트림으로 기다린다.

사용하는 것: Observable.FromCoroutine

결과: IObservable<Unit>

첫 번째 인수: Func<IEnumerator> coroutine 코루틴 본체

두 번째 인수: bool publishEveryYield = false yield 한 시간에 OnNext를 발행 하는가?

(false는 OnCompleted 직전에 1번만 발급 default = false)

Observable.FromCoroutine을 이용하면 코루틴 종료 시간을 스트림으로 처리 할 수 있습니다.

코루틴 종료 타이밍의 통지를 필요로 할 때 사용할 수 있습니다.

using System.Collections;
using UniRx;
using UnityEngine;

public class ConvertFromCoroutine : MonoBehaviour
{
private void Start() =>
Observable.FromCoroutine(NantokaCoroutine, publishEveryYield: false)
.Subscribe(
_ => Debug.Log("OnNext"),
() => Debug.Log("OnCompleted")
).AddTo(gameObject);

private IEnumerator NantokaCoroutine()
{
Debug.Log("Coroutine started");

// 어떤 처리를 하고 기다리고 있는 예
yield return new WaitForSeconds(3);

Debug.Log("Coroutine finished.");
}
}

실행 결과

Coroutine started.
Coroutine finished.
OnNext
OnComplted

Observable.FromCoroutineSubscribe 될 때마다 새롭게 코루틴을 생성하고 시작하게 된다는 것에 주의하십시오. 코루틴 하나만 시작 스트림을 공유하고 이용하고 싶다면 [스트림의 Hot 변환]({% post_url 2019-10-13-UniRx-When-is-a-Hot-Conversion %})이 필요 합니다.

덧붙여 Observable.FromCoroutine 에서 시작한 코루틴은 Subscribe를 Dispose하면 자동으로 중지 됩니다.

만약 코루틴에서 자신의 스트림이 Dispose 된 것을 감지하려면 코루틴의 인수 CancellationToken을 전달하여 Dispose를 감지 할 수 있습니다. 이때 CancellationToken은 Observable.FromCoroutine 에서 얻을 수 있습니다.

Observable.FromCoroutine(token => NantokaCoroutine(token))  // token이 CancellationToken

Ⅱ. 코루틴의 yield return 결과를 추출

사용하는 것: Observable.FromCoroutineValue<T>

결과: IObservable<T>

첫 번째 인수: Func<IEnumerator> coroutine 코루틴 본체

두 번째 인수: bool nullAsNextUpdate = true null일 때 OnNext를 발행하지 않는다. default = true

Observable.FromCoroutineValue<T>를 이용하면 코루틴의 yield return으로 반환 된 값을 꺼내 스트림으로 사용할 수 있습니다.

yield return는 호출 될 때마다 1 프레임 정지하는 성질이 있기 때문에 이를 이용하여 한 프레임 씩 값을 발행 할 수 있습니다.

using System.Collections;
using System.Collections.Generic;
using UniRx;
using UnityEngine;

public class ConvertFromCoroutine2 : MonoBehaviour
{
// 이동 좌표 목록
[SerializeField] private List<Vector2> moveList;

private void Start() =>
Observable.FromCoroutineValue<Vector2>(MovePositionCoroutine)
.Subscribe(x => Debug.Log(x));

/// <summary>
/// 목록에서 값을 1 프레임씩 꺼내는 코루틴
/// </summary>
/// <returns></returns>
private IEnumerator MovePositionCoroutine()
{
foreach (var v in moveList)
{
yield return v;
}

// ↑의 foreach 문은 통째로
// "return moveList.GetEnumerator ();"
// 로 고쳐 써도 된다.
}
}

실행 결과

Ⅲ. 코루틴 내부에서 OnNext를 직접 발행하기

사용하는 것: Observable.FromCoroutine<T>

결과: IObservable<T>

첫 번째 인수: Func<IObserver<T>, IEnumerator> coroutine IObserver<T>를 인수로 취하는 코루틴

Observable.FromCoroutine<T>IObserver<T>를 제공하는 구현도 존재 합니다. 이 IObserver<T>를 코루틴에 전달하여 코루틴의 특정 타이밍에 OnNext를 발행 할 수 있습니다.

이 기능을 이용하면 내부 구현은 절차적 비동기 처리로 쓰고 외부에서는 스트림으로 취급하는 것 처럼 코루틴과 UniRx 모두의 장점을 취할 수 있습니다.

매우 편리하고 범용적인 기능이므로 꼭 기억하세요.

또한 OnCompleted는 자동으로 발급되지 않기 때문에, 코루틴 종료 시점에서 스스로 OnCompleted를 발금해 줄 필요가 있습니다.

using System;
using System.Collections;
using UniRx;
using UnityEngine;

public class ConvertFromCoroutine3 : MonoBehaviour
{
// 일시 정지 플래그, true인 경우 타이머 중지
public bool IsPaused;

private void Start() =>
Observable.FromCoroutine<long>(observer => CountCoroutine(observer))
.Subscribe(x => Debug.Log(x))
.AddTo(gameObject);

/// <summary>
/// 일시 정지 플래그가 지나지 않은 상태의 시간(초)를 계산하여 알려준다.
/// </summary>
/// <param name="observer">알림 IObserver</param>
/// <returns></returns>
IEnumerator CountCoroutine(IObserver<long> observer)
{
long current = 0;
float deltaTime = 0;

// Dispose하면 코루틴이 멈추니까 while(true) 해도 문제없이 움직인다.
// 기분 나쁘다면 CancellationToken을 받아 이용하면 된다.
while (true)
{
if (!IsPaused)
{
// 일시 플래그가 지나지 않은 사이 시간을 측정한다.
deltaTime += Time.deltaTime;
if (deltaTime >= 1.0f)
{
// 차이가 1초를 초과한 경우 정수 부분을 꺼내 집계 통지한다.
var integerPart = (int) Mathf.Floor(deltaTime);
current += integerPart;
deltaTime -= integerPart;

// 시간(초) 통지
observer.OnNext(current);
}
}
yield return null;
}
}
}

실행 결과

(일시 정지 플래그가 true 동안 카운트를 정지 해, false가 되면 중단된 이전의 카운트에서 측정을 재개)

"상태에 의존한 처리" 나 "중간에 처리가 크게 분기되는 처리" 같은 것은 UniRx 오퍼레이터 체인만으로 구현하기 어렵고, 경우에 따라서는 구현 불가능한 경우도 있습니다. 그런 경우 이렇게 코루틴에서 내부 구현을 실시하고 스트림으로 변환 해버리는 방법을 취하는 것을 권장합니다.

Ⅳ.보다 저렴한 비용으로 가벼운 코루틴을 실행

사용하는 것: Observable.FromMicroCoroutine / Observable.FromMicroCoroutine<T>

반환 값: IObservable<Unit> / IObservable<T>

첫번째 인수: Func<IEnumerator> coroutine / Func<IObserver<T>, IEnumerator> coroutine

인수: FrameCountType frameCountType = frameCountType.Update Update, FixedUpdate, EndOfFrame 어느 타이밍을 이용할것인지

Observable.FromMicroCoroutine 그리고 Observable.FromMicroCoroutine<T>는 각각 이전에 설명했습니다. Observable.FromCoroutine / Observable.FromCoroutine<T>와 거의 같은 동작을 합니다.

그러나 내부 구현은 크게 다르며, 코루틴에서 yield return null 만 사용할 수 있는 제약이 있는 대신 Unity 표준 코루틴에 비해 매우 고속으로 동작하는 구조로 되어 있습니다. 이 구조의 코루틴을 "마이크로코루틴"이라 부르며 UniRx의 독자 구현으로 되어 있습니다.

yield return null만 구현되어 있는 코루틴을 만들고 시작하려면 Unity 표준의 StartCoroutine 보다 이 Observable.FromMicroCoroutine를 사용하면 보다 더 저렴한 비용으로 코루틴을 사용할 수 있습니다.

private void Start() =>
Observable.FromMicroCoroutine<long>(observer => CountCoroutine(observer))
.Subscribe(x => Debug.Log(x))
.AddTo(gameObject);

코루틴에서 IObservable로 변환하는 방법 정리

  • 코루틴에서 IObservable로 변환 할 수 있다.
  • Observable.FromCoroutine 등으로 실행한 코루틴은 MainThreadDispatcher에 관리가 위임되므로 수명 관리에 주의 할 필요가 있다 (AddTo 기억)
  • Observable.FromCoroutine 등은 Subscribe 된 시점에서 새롭게 코루틴을 생성하고 시작되어 버리기 때문에, 1개의 코루틴을 공유하고 여러 번 Subscribe 할 때는 Hot 변환이 필요하다.

3. IObservable에서 코루틴으로 변환

두 번째 방법으로 UniRx 스트림을 코루틴으로 변환하는 방법을 소개 하겠습니다.

이 스트림을 코루틴으로 변환하는 기술을 이용하여 코루틴에서 스트림의 실행 결과를 기다리고 그대로 계속 진행하는 등의 기술 방법이 가능합니다.

"C# Task와 await에 해당한다"라고 간략하게 생각 해두면 좋을 것 같습니다.

스트림을 코루틴으로 변환 (Unity 5.3)

사용하는 것: ToYieldInstruction() (IObservable<T>에 대한 확장 메서드)

결과: ObservableYieldInstruction<T>

인수: CancellationToken cancel 처리를 중단한 경우는 인수에 전달 한다 (생략가능)

인수: bool throwOnError = false OnError가 발생했을 때 예외 내용을 throw 할 것인가?

ToYieldInstruction 를 이용하여 스트림을 코루틴으로 실행 한 다음 스트림을 기다리게 할 수 있습니다.

using System;
using System.Collections;
using UniRx;
using UniRx.Triggers;
using UnityEngine;

public class ConvertToCoroutine : MonoBehaviour
{
private void Start()
{
StartCoroutine(WaitCoroutine());
}

private IEnumerator WaitCoroutine()
{
// Subscribe 대신 ToYieldInstruction()을 이용하여
// 코루틴으로 스트림을 처리 할 수 있게 된다

// 1초 기다린다
Debug.Log("Wait for 1 second.");
yield return Observable.Timer(TimeSpan.FromSeconds(1)).ToYieldInstruction();

// ToYieldInstruction()은 OnCompleted가 발행되어 코루틴 종료
// 따라서 OnCompleted가 반드시 발행되는 스트림에서만 사용할 수 있다.
// 무한으로 이어지는 스트림의 경우 First나 FirstOrDefault를 사용하면 좋겠다.
Debug.Log("Press any key");

// 아무 키나 누를 때까지 기다린다
yield return this.UpdateAsObservable()
.FirstOrDefault(_ => Input.anyKeyDown)
.ToYieldInstruction();

// FirstOrDefault 조건을 충족하면 OnNext와 OnCompleted를 모두 발행한다.
Debug.Log("Pressed");
}
}

ToYieldInstructionOnCompleted 메시지를 받으면 yield return을 종료합니다. 따라서 OnCompleted를 발행하지 않느 끝없는 스트림을 ToYieldInstruction 해 버리면 영원히 끝없는 상태가 되어 버리기 때문에 주의가 필요합니다.

또한 스트림에서 발행된 OnNext 메시지를 이용하는 경우 ToYieldInstruction 가 반환하는 ObservableYieldInstruction<T>로 변수에 저장한 결과를 가져올 수 있습니다.

using System;
using System.Collections;
using UniRx;
using UniRx.Triggers;
using UnityEngine;

public class ConvertToCoroutine2 : MonoBehaviour
{
private void Start() => StartCoroutine(DetectCoroutine());

private IEnumerator DetectCoroutine()
{
Debug.Log("Coroutine start!");

// 코루틴이 시작되고 나서
// 3초 이내에 먼저 자신을 건드린 객체를 얻는다.
var o = this.OnCollisionEnterAsObservable()
.FirstOrDefault()
.Select(x => x.gameObject)
.Timeout(TimeSpan.FromSeconds(3))
.ToYieldInstruction(throwOnError: false);

// Timeout은 지정 시간 이내에 스트림이 완료되지 않는 경우
// OnError를 발행하는 오퍼레이터

// 결과를 기다린다.
yield return o;

if (o.HasError || !o.HasResult)
{
// 아무것도 치지 않았다.
Debug.Log("hit object is nothing.");
}
else
{
// 뭔가에 맞았다.
var hitObject = o.Result;
Debug.Log(hitObject.name);
}
}
}

스트림을 코루틴으로 변환 (Unity 5.2 이전)

Unity 5.2 이전에는 ToYieldInstruction을 사용할 수 없습니다. 대신 StartAscoroutine를 사용하여 동일한 작업을 수행 할 수 있습니다.

IEnumerator DetectCoroutine()
{
GameObject result = null;
bool isTimeout = false;

// 코루틴이 시작되고 나서
// 3초 이내에 먼저 자신에 닿은 오브젝트를 취득하는
yield return this.OnCollisionEnterAsObservable()
.FirstOrDefault()
.Select(x => x.gameObject)
.Timeout(TimeSpan.FromSeconds(3))
.StartAsCoroutine(x => result = x, error => isTimeout = true);

// StartAsCoroutine는 첫 번째 인수의 함수 결과가 전달되기 때문에
// 그래서 사전에 정의 된 변수에 결과를 대입하여 결과를 얻을 수
// 두 번째 인수는 OnError
if (isTimeout || result == null)
{
Debug.Log("hit object is nothing.");
}
else
{
var hitObject = result;
Debug.Log(hitObject.name);
}
}

IObservable에서 코루틴으로 변환하는 방법 정리

  • ToYieldInstruction 또는 StartAsCoroutine를 이용하여 스트림을 코루틴으로 변환 할 수 있다.
  • 응용하면 "코루틴 도중 특정 이벤트의 발행을 기다린다" 같은 처리가 가능하게 된다.

응용 예

코루틴을 직렬로 실행하고 기다린다

CoroutineA 실행 → CoroutineA의 종료를 받고 CoroutineB 시작

private void Start() =>
Observable.FromCoroutine(CoroutineA)
.SelectMany(CoroutineB) // SelectMany에서 합성
.Subscribe(_ => Debug.Log("All Coroutine Finished"));

private IEnumerator CoroutineA()
{
Debug.Log("CoroutineA start");
yield return new WaitForSeconds(3);
Debug.Log("CoroutineA finished");
}

private IEnumerator CoroutineB()
{
Debug.Log("CoroutineB start");
yield return new WaitForSeconds(3);
Debug.Log("CoroutineB finished");
}

실행 결과

CoroutineA start
CoroutineA finished
CoroutineB start
CoroutineB finished
All coroutine finished

여러 코루틴을 동시에 시작하고 결과를 기다린다

CoroutineA와 CoroutineB를 동시에 시작하고 모두 종료하고 정리해 처리

private void Start() =>
Observable.WhenAll(
Observable.FromCoroutine<string>(o => CoroutineA(o))
, Observable.FromCoroutine<string>(o => CoroutineB(o))
).Subscribe(xs =>
{
foreach (var x in xs)
{
Debug.Log("result: " + x);
}
});

private IEnumerator CoroutineA(IObserver<string> observer)
{
Debug.Log("CoroutineA start");
yield return new WaitForSeconds(3);
observer.OnNext("CoroutineA done!");
observer.OnCompleted();
}

private IEnumerator CoroutineB(IObserver<string> observer)
{
Debug.Log("CoroutineB start");
yield return new WaitForSeconds(1);
observer.OnNext("CoroutineB done!");
observer.OnCompleted();
}

실행 결과

CoroutineB start
CoroutineA start
result : CoroutineA done!
result : CoroutineB done!

무거운 처리를 다른 스레드에 하면서 결과를 코루틴에서 얻는다.

코루틴에서 일부 처리를 다른 스레드에서 실행하고 결과가 돌아 오면 처리를 코루틴에서 재개하도록 구현.

Observable.Start() 을 이용한다.

private void Start() => StartCoroutine(GetEnemyDataFromServerCoroutine());

/// <summary>
/// 서버에서 적의 정보를 당겨오는 코루틴
/// </summary>
/// <returns></returns>
private IEnumerator GetEnemyDataFromServerCoroutine()
{
// 서버에서 xml 다운로드
var www = new WWW ("http://api.hogehoge.com/resouces/enemey.xml");

yield return www;

if (!string.IsNullOrEmpty(www.error))
{
Debug.Log(www.error);
}

var xmlText = www.text;

// ParseXml 함수를 다른 스레드에서 실행
// Observable.Start는 인수의 함수를 ThreadPool에서 실행하는 기능
var o = Observable.Start(() => ParseXml(xmlText)).ToYieldInstruction();

// 파스 종료 대기
yield return o;

if (o.HasError)
{
// 파스 실패
Debug.LogError(o.Error);
yield break;
}

// 파스 결과
var result = o.Result;
Debug.Log(result);

// 이 후 처리 계속
}

private Dictionary<string, EnemyParameter> ParseXml(string xml)
{
// 여기에 xml 파싱을 Dictinonary에 넣는다는 가정
return new Dictionary<string, EnemyParameter>();
}

/// <summary>
/// 적 매개 변수
/// </summary>
private struct EnemyParameter
{
public string Name { get; set; }
public string Health { get; set; }
public string Power { get; set; }
}

(↑ 구현 보다 ↓ 작성 한 것이 간단하게 작성되지만, 어디 까지나 코루틴을 사용하면 어떻게 될까 설명 이므로 용서 바랍니다.)

ObservableWWW.Get("http://api.hogehoge.com/resouces/enemey.xml") 
.SelectMany(x => Observable.Start(() => ParseXml(x)))
.ObserveOnMainThread() // 처리를 메인 스레드 취소
.Subscribe(result =>
{
// 여기에 퍼스 결과를 사용한 처리
}
ex => Debug.LogError(ex)
);

정리

  • 스트림과 코루틴은 상호 변환 할 수 있다.
  • 코루틴을 이용하여 오퍼레이터 체인으로는 만들 수 없는 스트림을 구축하는 것이 가능하게 된다.
  • UniRx의 독자 코루틴을 사용하는 것으로, Unity 표준 코루틴보다 사용이나 성능이 향상 될 수 있다.
  • 스트림을 코루틴으로 변환하여 async/await 같은 기능이 가능하다 (어디 까지나 비슷한 기능)

· 약 37분
karais89

환경

  • macOS Catalina v10.15
  • Unity 2019.2.10f1
  • Github Desktop
  • Rider 2019.2
  • UniRx v7.1.0

원문 : https://qiita.com/toRisouP/items/2a1d4185d7f54e0cca24

이 포스팅은 원문을 단순히 구글 번역을 하여 정리한 내용입니다. 일본어를 잘하시는 분은 원문을 보시는게 더 좋으실 것 같습니다.

자기 소개

  • 이름: とりすぷ( @toRisouP )
  • 취미로 Unity를 이용해 게임 개발을 하고 있습니다.

지금 만들고 있는 게임

아이템을 자기 진영에 넣으면 점수가 나는 게임 입니다.

이번 내용

  • ハクレイフリーマーケット 개발을 통해 얻은 지식과 노하우를 정리합니다.
  • 1인 개발에서 얻은 지식이므로, 팀 개발에 그대로 적용 할 수 없을지도 모릅니다
    • 프로그래머 관점에서의 내용 입니다.
  • 생각나는대로 썻기 때문에 내용에 맥락은 없습니다.
    • 궁금하신 부분만 발췌해서 읽으세요.

정리 목록

크게 2가지가 있습니다.

  • 추천 에셋 서비스 편
  • 프로그래밍 편

추천 에셋 서비스 편

추천 에셋 서비스

  • 어떤 기능이 필요하게 되었을 때 이용한 에셋이나 서비스를 소개 합니다.

1. 네트워크를 만들고 싶다

사용한 것 : Phton Cloud

Photon

  • 일본에서는 GMO 인터넷에서 제공하는 네트워크 엔진
  • 서버 클라이언트 형태의 네트워크를 만들 수 있게 된다
  • 그 중에서도 "Photon Cloud"는 서버를 클라우드로 제공 해준다.
    • Unity 용 SDK도 공개되어 있다 (PUN)
    • 20명 동시 연결까지는 무료

공식 사이트

Unity + Photon Cloud에서 할 수 있는 일

  • 방을 만들고 참가자를 모집한다.
  • Transform, Animator등 다른 임의의 정보를 네트워크를 통해 동기화 할 수 있다.
  • 네트워크를 통해 메서드를 호출 해 실행할 수 있다.

네트워크 통신에 필요한 것은 대략 적으로 지원하고 있다.

그러나 네트워크를 쉽게 만들 수 있다고는 말하지 않았다!

네트워크 개발의 어려움

  • 생각해야 될 상태가 늘어난다
    • 같은 객체가 "로컬"과 "네트워크 너머 상대방의 세계(리모트)" 양쪽에 동시에 존재한다.
    • 로컬 및 원격 세계의 상태를 고려하여 객체를 조작하지 않으면 안된다
  • 간단한 것도 비동기 처리 해야 된다
    • 단지 "떨어진 아이템을 줍는다"라는 처리만으로도 매우 귀찮아진다.
    • 참고: 아이템 복사 방지
  • 디버깅이 매우 어렵다
    • 통신시의 미묘한 시기에 발생하는 버그 라든지 재현이 어렵다.
  • 통신 상대가 없으면 디버깅 및 레벨 디자인을 할 수 없다.
    • 동작 확인을 위해 여러 플레이어 필요
    • 실시간으로 사람을 모을 수 밖에 없다.

게임 개발 초보자에게는 네트워크 개발은 추천 할 수 없다

  • Photon이 지원해주는 것은 어디 까지나 통신의 낮은 레이어 부분 만 지원해주고, 응용프로그램은 결국 자신이 구현해야 한다.
  • 오프라인 게임에 비해 네트워크 개발은 구현하지 않으면 안되는 것이 몇 배는 있다
  • 어느정도 개발 경험을 쌓고 프로그래밍에 자신감이 붙은 사람이 아니면 손을 대는 것은 위험하다고 생각한다.

2. 사용자 관리가 하고 싶다

사용한 것: 니프티 클라우드 mobile backend (NCMB)

NCMB

  • 니프트에서 제공하는 mBaaS 서비스
    • (mBaaS : Mobile Backend as a Service)
  • 회원 가입 기능, 데이터 저장, 푸시 알림 등의 서비스를 이용할 수 있다.
  • 200만 API/월 요청까지라면 무료로 이용할 수 있다.

공식 사이트

[역주]

비슷한 서비스로 PlayFab 이나 Firebase도 제공하고 있다.

한국에서는 이 2개의 서비스로 시작하는게 더 나을 것 같다.

NCMB에서 사용자 관리

  • NCMB를 사용하면 간단하게 게임에 회원 가입 및 로그인 기능을 구현할 수 있다.
  • 이메일 주소 인증에도 대응 된다.
  • 자기 부담으로 계정 관리 서버를 만들지 않아서 정말 고맙다.

3. 제품 정품 인증 기능을 원한다.

사용한 것 : 없다

왜?

  • 일련 번호를 가진 사람만 즐길 수 있는 기능을 원한다.
    • 실제 구입한 사람만 즐길 수 있게 하고 싶다
  • 시리얼 정품 인증을 제공 해주는것은 좋은 느낌의 서비스는 존재하지 않는다.

결국 어떻게 했는지?

  • 자기 부담으로 인증 서버를 만들 수 밖에 없었다.
    • Ruby on Rails로 빠르게 Web 서비스를 만들 수 잇어서 좋았다.
  • 자기 부담 서버와 NCMB를 연계시켜 구입자, 미 구매자 관리를 했따.

자세한 내용은 Unity와 NCMB에서 사용자 관리를 구현 해본 이야기를 참조

4. NPC 상대를 만들고 싶다

사용한 것

Behaviour Designer

Behaviour Designer

  • BehaviourTree를 GUI에서 만들 수 있는 에셋
  • NPC의 행동을 최소 단위로 분할하고 그들을 결합하여 사람 같이 움직이게 할 수 있다.
  • 상태 머신을 짜는 것에 비해 매우 알기 쉽게 NPC를 만들 수 있으므로 추천한다.

완성된 실제 Tree

  • 만드는데 걸린 시간은 15 시간 정도
  • 70% 정도는 자작 Task 이다.

실제로 만든 NPPC의 동영상

https://www.youtube.com/watch?v=nLsR3nrmppQ

5. 게임 패드의 관리를 하고 싶다.

사용한 것

Rewired

Rewired

  • 게임 패드의 관리에 특화된 에셋
  • 게임 패드의 자동 검출및 자동 할당을 동적으로 해준다.
  • 게임패드의 형태에 따라 적절하게 변경해준다.
  • 방대한 종류의 게임 패드가 처음부터 등록되어 있다.
    • 일단 수중에 있던 게임 패드는 대략 대응 된다.

[역주]

Unity에서 새로 발표한 new Input System이 기능을 대체할 수 있을지 확인해봐야 될 것 같다.

6. 물리 기반 CharacterController을 원한다.

사용한 것

Easy Character Movement

Easy character Movement

  • 물리 연산과 상호 간섭하면서 작동하는 캐릭터를 쉽게 만들 수 있는 에셋
  • CharacterController과 같은 인터페이스에서 RigidBody를 취급 할 수 있다.
    • Move() 메서드로 이동하면서 AppleForce()로 날려 버린다 같은 것을 할 수 있다.
    • 움직이는 바닥의 영향을 받게 하고 싶다.

7. 개체를 눈에 띄게 하고 싶다

사용한 것

Highlighting System

Highlighting System

  • 스크립트를 연결하면 GameObject에 윤곽선을 달 수 있다.
    • 특별한 쉐이더를 사용하지 않고 스크립트만으로 가능하다.
  • 점멸 기능과 벽을 투명하게 그리는 기능도 있다.
  • 플레이어에 윤곽을 붙여두면 시인성이 향상되므로 추천 한다.

추천 자산 서비스 편 완료

프로그래밍 편

프로그래밍 편

  • Unity에서 프로그램을 해오면서 발생한 문제와 그 해결책을 소개 한다.
  1. 형 안전이 아닌 곳에서 죽는 문제
  2. 매니저 싱글톤의 배치 문제
  3. GOD 클래스가 있는 문제
  4. 컴포넌트간의 연계 방법
  5. View와 Model의 연계

1. 타입 안전이 아닌 곳에서 죽는 문제

"시작은 하는데 왠지 동작이 이상하다. 움직이지 않는다."

"컴파일 에러는 안났지만, 움직일때 에러가 난다"

타입 안전은?

  • 프로그램 작성을 잘못 했을 때 제대로 컴파일 오류가 발생한 상태
    • "타입"에 의해 프로그램의 정당성이 담보된 상태의 수
    • 타입 안전성이 없는 경우, 어딘가 기술을 잘못해도 인간이 그 실수를 알아차릴 방법이 없다.
    • 버그가 발생했을 때, 수상한 곳을 중단점으로 코드를 체크하게 된다.

Unity에서 타입 안전이 아닌 기능

  • SendMessage
  • Tag
  • Scene 전환
  • Invoke
  • Animator 플래그 지정

대부분 문자열 기반으로 처리를 하고 있는 곳

예: 태그

public void OnCollisionEnter(Collision collision)
{
// Enemy의 철자가 틀렸지만 컴파일 에러는 되지 않는다!
if (collision.gameObject.tag == "Eneny")
{
// 처리 부분
}
}

예: Animator 애니메이션 플래그

// IsRunning을 철자가 틀렸지만, 컴파일 에러는 되지 않는다!
animator.SetBool("IsRuning", true);

대책

문자열 기반의 처리를 없앤다

타입 안전하게 쓰는 방법

  • 문자열을 사용하여 동작을 제어하고 있는 것이 악의 근원이다.
  • 문자열을 사용 장소를 제한하고 가능한 "타입"의 형태 모양으로 변경하면 된다.
    • enum을 사용 하든지, 인터페이스를 사용 하든지, 프로퍼티로 감싸던지

예 1

  • Tag 비교에 enum을 사용
enum EntityType
{
Player,
Enemy,
Boss
}

public void OnCollisionEnter(Collision collision)
{
// enum의 ToString은 느리지만..
if (collision.gameObject.tag == EntityType.Enemy.ToString())
{

}
}

예 2

  • 원래 Tag를 사용하지 않고 타입으로 비교
public void OnCollisionEnter(Collision collision)
{
// IEnemy 인터페이스를 구현 한 컴포넌트를 가지고 있는지 알아
var enemyComponent = collision.gameObject.GetComponent<IEnemy>();
if (enemyComponent != null)
{
// 처리
}
}

예 3

  • 애니메이션 플래그를 프로퍼티로 감싼다.
Animator animator;

/// <summary>
/// 이동 애니메이션 플래그
/// </summary>
private bool IsRunning
{
// "IsRunning"라는 문자열이 등장하는 것은 이곳이 유일하다.
// (여기 만 오탈자가 발생하지 않도록 주의 해두면 된다)
set { animator.SetBool("IsRunning", value); }
}

void Start()
{
animator = GetComponent<Animator>();

// bool을 대입하는 것만으로 사용할 수 있다.
IsRunning = true;
}

"타입 안전이 아닌 곳에서 죽는 문제" 요약

  • int 형과 string 타입을 사용하여 조건 판전을 실시하고 있는 곳은 실수가 발생하기 쉽다.
  • 인터페이스를 만들거나, enum을 사용하거나, 프로퍼티로 감싸던지 어쨌든 원시적인 형태가 노출되지 않도록 유의하면 된다.

2. 매니저 싱글톤의 배치 문제

"Editor 상에서 씬을 지정해서 실행하면 에러가 발생한다."

"같은 게임 오브젝트가 2개가 존재한다"

매니저 싱글톤

  • 씬을 횡당하여 리소스 등을 계속 관리하는 싱글톤
    • 장면 전환시 전환 애니메이션 관리
    • 사운드 관리
    • 게임의 설정 항목 관리
  • 항상 1개 이며, 게임의 실행 중에 존재하지 않으면 게임이 올바르게 동작하지 않는다.

매니저 싱글톤은 어떻게 초기화 하나?

  • 씬에 Prefab화한 싱글톤을 배치하는 것 만으로 괜찮지 않나?
    • DontDestroyOnLoad로 지정하고 한 번 배치하면 사라지지 않도록 한다.

안됩니다.

씬에 처음부터 싱글톤을 놓아 두면 안되는 이유

  • 싱글톤을 배치하고 있지 않는 씬에서 게임을 실행하면 싱글톤이 존재하지 않으면 죽는다.
    • Editor에서 디버깅 중에 발생한다.
    • 전체 씬에 싱글톤을 배치하는 것 같은 것은 하고 싶지 않다.
  • 싱글톤이 존재하는 씬을 방문할때마다 하나씩 생성되어 증식된다.
    • 다중 생성되면 지우는 것도 근본적인 해결책이 되지 않는다.

대책

"필요한 타이밍에서 처음으로 싱글톤을 동적으로 생성하면 된다"

필요할때

  • 이미 싱글톤이 존재한다면 그것을 사용한다.
  • 없다면 새로 생성 한다.

이것만으로 해결 할 수 있다 (즉 단순한 지연 초기화)

구현 예

using UnityEngine;

/// <summary>
/// 씬 전환을 실시하는 클래스
/// </summary>
public static class SceneLoader
{
/// <summary>
/// 매니저 싱글톤의 Prefab 경로
/// </summary>
private static readonly string managerPrefabPath = "Managers/TransitionManager";

/// <summary>
/// 싱글턴 전환 애니메이션 제어 구성 요소
/// </summary>
private static TransitionManager _transitionManager;

/// <summary>
/// 이미 싱글 톤이 존재한다면 그것을 돌려주고, 없는 경우 만든다.
/// </summary>
private static TransitionManager TransitionManager
{
get
{
if (_transitionManager != null) return _transitionManager;
if (TransitionManager.Instance == null)
{
var resource = Resources.Load(managerPrefabPath);
Object.Instantiate(resource);
}
_transitionManager = TransitionManager.Instance;
return _transitionManager;
}
}

/// <summary>
/// 씬 전환을 시작
/// </summary>
/// <param name="scene"> 다음 씬 </param>
public static void LoadScene(GameScenes scene)
{
TransitionManager.StartTransaction(scene);
}
}

static 클래스에 매니저 싱글톤의 존재 확인을 끼워 없으면 생성시킨다.

구현 예

SceneLoader.LoadScene(GameScenes.Title);

호출자는 싱글톤을 의식하지 않고 static 클래스에 구현된 메소드를 실행 한다.

매니저 싱글톤의 배치 문제 요약

  • 지연 초기화를 이용하면 해결 할 수 있다.
  • 그러나 처음 액세스 할 때 싱글톤의 생산 비용이 발생하는 단점이 있다.
    • 스탠드얼론일때는 기동 직후 장면에서 취합하여 초기화를 실행한다.
    • 에디터 실행시에만 이 지연 초기화를 사용한다.
    • 라고 하면 좋을지도 모르겠다.

3. GOD 클래스가 있는 문제

"이 클래스는 1000 라인이 넘어 간다."

"필드 변수와 메소드가 많이 있어 어떻게 의존하고 있는지 모르겠다!"

"Update()의 내용이 위험하다!"

[역주]

1000 라인이 넘어간다고 무조건 GOD 클래스는 아니다.

GOD 클래스?

  • 여러가지 기능이 담겨있어 거대한 클래스
  • 피해야 할 안티 패턴
  • 대충 만들다 보면 이렇게 된다.
    • Unity의 공식 튜토리얼의 프로젝트를 그대로 확장 해 나가다보면 GOD 클래스가 되기 쉽다.

GOD 클래스인 PlayerController

  • Input 관리, 이동, 공격, 데미지, 애니메이션, 음향 효과, 입자 효과, UI 관리 등..
  • 이러한 처리가 모두 하나의 클래스에 정의되어 있다.
    • 상태를 나타내는 플래그가 난무하고 Update() 안에서도 이것저것 구현 되어 있다.

이렇게 만드는 사람들이 많을 것이다.

대책

컴포넌트 분할

컴포넌트 분할

  • 단일 책임 원칙을 의식하고 구성 요소를 분할한다.
    • 컴포넌트 1개는 1개의 역할만 담당한다.
    • 이동 처리는 PlayerMover 에서, 애니메이션 관리는 PlayerAnimation 같이 처리 한다.
  • 1개의 컴포넌트가 다루는 영역이 좁아지기 때문에 필요한 최소한의 필드와 메서드만 구현되서 코드의 품질이 좋아진다.

컴포넌트를 분할 할 때 조언

  • 그 컴포넌트가 제공하는 "기능"에 관계있는 처리만을 구현
    • "이동"에 특화된 컴포넌트는 이동 처리에 필요한 것만 제공한다.
    • 자신의 책임 이외의 처리는 다른 컴포넌트에 위양한다.

컴포넌트 분할의 장점

  • 1개의 컴포넌트가 맡는 책임이 명확해진다.
    • 불필요한 일을 생각하지 않고 지금 컴포넌트가 할 일만 생각하고 구현하면 된다.
  • 어떤 처리가 어떤 컴포넌트에 쓰여져 있는지 알기 쉽게 된다.
    • 클래스의 이름으로 처리 위치를 찾을 수 있게 된다.

컴포넌트 분할의 단점

  • 방대한 수의 컴포넌트를 연결해야 하며, 그 관리가 힘들어진다.
    • 악마 같은 RequireComponent를 쓰는 것도 고충이다.
    • MonoBehaviour을 상속하지 않는 Pure한 클래스를 생성하고 그 쪽에 처리를 위양하는 방법도 있다.
  • Update 호출 비용이 증가 한다.
    • (UniRx의 ObservableUpdateTrigger를 사용하면 Update 비용은 일단 줄일 수 있다.)
  • 뒤에서 다룰 "종속성"과 "클래스 간의 연계" 문제가 나온다.

하나님 클래스는 절대 악인가?

  • 규모가 작고 감당할 수 있는 범위라면 모든 처리를 하나의 클래스에 담아도 된다.
  • 처음에는 일단 프로그래밍 하고, 코드에 악취가 날때 분할 하는 방식도 괜찮다.

3. 의존, 참조 관계가 복잡해지는 문제

"어떤 컴포넌트가 어디에 의존하는 거야?"

"컴포넌트를 재사용 하고 싶지만, 어쩐지 여러곳에 의존하고 있어 사용할 수 없다!"

"어? 상호 참조하고 있어 초기화에 실패 하고 있다."

종속성이 복잡해지는 문제

  • 분할된 컴포넌트를 계속 연결해 나가면 복잡해진다.
  • 어디를 만지면 어디에 영향을 미칠지 예측 불가능 해진다.

대책

구현하기 전에 클래스 다이어그램을 작성하고 설계한다.

설계를 제대로 하자

  • 복잡해지는 원인은 대부분 닥치는 대로 구현하기 때문이다.
    • 사양이 원래 복잡한 경우는 어쩔 수 없다.
  • 설계 클래스 다이어그램을 만들어두면 구현 작업을 분담 할 수 있게 된다.
    • 누가 구현해도 설계대로 만들어질 것이다.
  • 잡다한 클래스 다이어그램도 적어 놓으면 나중에 전체 형태를 파악이 가능하게 된다.

클래스 다이어그램을 작성하는 추천 도구

  • PlantUML을 사용하면 쉽게 그림이 그려지므로 추천 한다.
    • 텍스트를 쓰는 것만으로 자동으로 클래스 다이어그램이 생성된다.

[역주]

Visual studio Code에서도 똑같은 확장 프로그램을 설치하여 실행 할 수 있다. 클래스 다아어그램을 문자로 관리를 하면 Git으로 버전관리도 되고, 협업도 된다.

실제로 작성한 클래스 다이어그램 예

클래스 설계시 조언

  • 상호 참조, 순환 참조를 가능한 피하자
    • 상호 참조, 순환 참조는 장점보다 단점이 더 눈에 띄기 때문에 안이하게 이용하지 말자
    • 닫힌 개념에서 국소적으로 사용할 정도로 억제해두면 좋다.
  • "추상화"와 "의존관계역전"을 이용하여 의존 관계를 정리하자.

의존관계역전

설계 노하우의 덩어리

  • SOLID 원칙은 확실히 알고 있으면 좋다.
  • "코드스멜"을 느낄 수 있게 되면 좋다.
    • 익숙해지면 어디를 추상화 해야 되는지 감각적으로 알게 된다.
  • 우선 여러 가지를 만들어 보고 어려움을 겪으면서, 거기에서 설계의 중요성을 실감하면 좋다.

이런 반론도 있었습니다.

"사양이 자주 바뀌기 때문에 설계를 못 해먹겠다!"

"사양이 자주 변하니까 설계하지 않는다"는 올바른가?

  • 솔직히 케바케라고 할 수 밖에 없다.
  • 변경에 유연하게 만드는 것도, YAGNI로 만드는 것도 있다.
    • (YAGNI: "You Ain't Gonna Need It". "필요한 작업만 해라", 사양 변경으로 유연하게 작업한 코드를 작성해 놓으면 코드가 불필요하게 장황해진다. 그러니 당장 필요한 작업에 집중하고 쓸데없는 작업은 하지 말라라는 개발 원칙) Simple is Best
  • 프로젝트에 따라 최적의 개발 스타일이 변화하기 때문에 설계를 안하는 것이 효율이 좋다면 이렇게 할 것이다.
    • 설계하는 것이 나중에 편해지는 경우가 더 많지만, "아무래도 설계하고 싶지 않다"라는 사람에게는 강요할 수 없다.

덧붙여서

'의존, 참고 관계가 복잡해지는 문제' 요약

  • 설계를 잘하는게 답
  • 클래스 다이어그램을 만들어두면 일단 구현헤서 해메지는 않을 것이다.

4. 컴포넌트간의 연계 방법

조각 조각낸 컴포넌트를 어떻게 조화시켜 동작시킬수 있을까?

  • "플레이어가 기절 하면, 이동하지 못하게 하고 기절 애니메이션을 재생하고 효과음을 재생하고 싶다"
  • 상태 관리를 일원화해서 그곳의 변화를 감지해서 마음대로 처리가 움직이는 형태로 하고 싶다.

컴포넌트간의 연계

  • 뭔가 있을 때마다 이벤트를 발행하는 것이 가장 편하다.
    • (Observer 패턴을 적용하여 연계시킨다)
  • 각 컴포넌트는 "OO가 일어나면 XX를 한다"는 것만을 의식하는 형태로 작성한다.

Observer 패턴?

Observer 패턴

  • 무엇인가 잘못되었을 때 감시 대상 측에서 이벤트를 날려주고 구독 측에서 처리하는 디자인 패턴
  • 의존 관계를 역전 할 수 있는 장점이 있다
    • (매 프레임 상태의 플래그를 감시 한다 같은 구현을 없앨 수 있다)

모르면 일단 "UniRx"를 사용하면 OK

UniRx

  • Reactive Extensions for Unity
  • 리액티브 프로그래밍을 Unity에서 사용할 수 있다
  • 할 수 있는 것이 너무 많아서 한마디로 설명 할 수 없다!
    • 과거에 정리한 포스트에 일부 설명하고 있기 때문에 그쪽을 참고하십시오.
    • 정리 링크

UniRx의 ReactiveProperty<T>

  • 이벤트 발행 기능을 가진 변수
  • 값이 바뀌면 통지가 날라온다.
  • 이것을 부모가 되는 클래스에 갖게 하고 자식이 이벤트를 기다리면 OK

구현 예

  • "플레이어가 기절 하면 이동할 수 없게 하고 기절 애니메이션을 재생 시키고 싶다"
  • UniRx를 이용한 구현 예를 소개한다.
using System.Collections;
using UniRx;
using UnityEngine;

public class PlayerHealth : MonoBehaviour
{
/// <summary>
/// 기절 플래그
/// </summary>
public BoolReactiveProperty IsStunned = new BoolReactiveProperty();

/// <summary>
/// 데미지 처리
/// </summary>
public void ApplyDamage()
{
// 기절하지 않았다면 기절 시킨다.
if (!IsStunned.Value) StartCoroutine(StunCoroutine());
}

/// <summary>
/// 기절하는 동안 수행되는 코루틴
/// </summary>
private IEnumerator StunCoroutine()
{
// 기절 플래그 ON
IsStunned.Value = true;
// 적당히 대기 한다.
yield return new WaitForSeconds(5);
// 기절 플래그 OFF
IsStunned.Value = false;
}
}

using UniRx;
using UnityEngine;

/// <summary>
/// 이동 관리 컴포넌트
/// </summary>
public class PlayerMove : MonoBehaviour
{
/// <summary>
/// 이동 허가 플래그
/// </summary>
public bool _canMove;

private void Start()
{
// 참조의 취득은 원하는 방식으로 구현
var playerHealth = GetComponent<PlayerHealth>();

// 기절 플래그가 변경되면 이동 허가 플래그에 반영한다.
playerHealth.IsStunned.Subscribe(x => _canMove = x);

// 다음 부분은 _canMove 플래그를 사용하여 이동 처리를 작성하는 로직
// 이하 생략
}
}

using UniRx;
using UnityEngine;

public class PlayerAnimation : MonoBehaviour
{
private Animator _animator;

private bool IsStunned
{
set => _animator.SetBool("IsStunned", value);
}

private void Start()
{
_animator = GetComponent<Animator>();

var playerHealth = GetComponent<PlayerHealth>();

// 기절 플래그를 써 고쳐하면 Animator의 기절 플래그에 반영
playerHealth.IsStunned.Subscribe(x => IsStunned = x);
}
}

using UniRx;
using UnityEngine;

public class PlayerSound : MonoBehaviour
{
private void Start()
{
var playerHealth = GetComponent<PlayerHealth>();

// 기절 상태에 맞추어 효과음을 재생, 중지
playerHealth.IsStunned.Subscribe(x =>
{
if (x)
{
Play();
}
else
{
Stop();
}
});

}

private void Play()
{
// 생략
}

private void Stop()
{
// 생략
}
}

"컴포넌트 연계 방법" 정리

  • 이벤트 기반 구현을 하는 것이 방법
    • Observer 패턴을 사용할 수 있다.
  • UniRx와 너무 잘 어울린다
    • 바로 리액티브 프로그래밍!

5. View와 Model의 연계

"어쩐지 Model (데이터 실체)과 View(UI)가 상호 의존적이다."

View와 Model이 상호 참조하면 여러가지 위험이 있다.

  • 데이터의 실체를 가지는 컴포넌트(Model)가 UI의 제어까지 해야 된다.
    • Model이 GOD 클래스화 된다.
  • UI의 교체가 어렵다.
    • Model이 View에 커플링이 되어 있기 때문에 쉽게 분리, 교체 할 수 없다.
  • UI를 재사용 할 수 없게 된다.
    • View에 필요한 로직이 Model에 작성되어 있기 때문에, 다른 Model에 같은 View를 적용하려고하면 Model의 구현마다 다시 작성해야 한다.

View와 Model을 잘 다루는 방법

  • 옛날부터 계속 논의되어 온 문제
  • 지금까지 MVC, MVP, MVVM 같은 아키텍쳐 패턴이 고안되어 왔다.
  • Unity에서 기분좋게 사용할 수 있는 패턴은 없는 걸까?

있습니다.

MV(R)P 패턴

MV(R)P 패턴

  • Model-View-(Reactrive)-Presenter 패턴
  • UniRx의 제작자 neuecc씨가 고안한 UniRx를 이용한 Unity의 UI 아키텍쳐 패턴
  • "Presenter"을 준비하고 Model과 View의 사이를 중개한다.
  • UniRx의 ReactiveProperty를 사용하는 것이 쉽다

참고 : UniRx 4.8 - 경량 이벤트 훅과 uGUI의 이벤트에 의한 데이터 바인딩

MV(R)P 패턴 적용 후

[역주]

MV(R)P 설계에서는 Presenter가 Model과 View을 소지하고있다. 따라서 Presenter는 Model과 View를 알고 있는데, 상호 참조를 피하기 위해 Model과 View는 Presenter를 모르는 구조로 되어 있다.

안드로이드의 MVP 예제들에서는 보통 View와 Presenter가 1:1로 서로 알고 있는 구조로 되어 있다. 두개의 공통점은 Model과 View는 서로 모른다는 점이다.

MV(R)P의 경우 MVP에서 변형된 아키텍쳐 패턴으로 유니티에 조금 더 특화된 패턴이라고 생각하면 될 것 같다. 실제로 Unity에서 UniRx를 사용하여 MV(R)P 아키텍쳐를 사용하능 방법에도 여러가지 방법으로 사용하기도 한다.

아래는 MVP 패턴에 대한 추가 링크

MV(R)P이 구현 예

  • uGUI의 InputField의 입력을 Model에 저장한다.
  • Model을 유지하는 데이터에 변경이 있는 경우 InputField도 업데이트 한다.
  • InputField = View 자체로 취급 한다.

Model

using UniRx;
using UnityEngine;

public class Model : MonoBehaviour
{
// 외부에 공개하는 데이터
public ReactiveProperty<string> Name = new ReactiveProperty<string>();
}

Presenter

using UniRx;
using UnityEngine;
using UnityEngine.UI;

/// <summary>
/// Model과 View를 연결하는 Presenter
/// </summary>
public class Presenter : MonoBehaviour
{
// view
[SerializeField]
private InputField _nameInputField;

// model
[SerializeField]
private Model _model;

private void Start()
{
// view가 업데이트되면 Model의 데이터를 업데이트 한다
_nameInputField
.OnValueChangedAsObservable()
.Subscribe(x => _model.Name.Value = x);

// model이 업데이트 되면 view를 다시 업데이트 한다.
_model.Name
.Subscribe(x => _nameInputField.text = x);
}
}

MV(R)P 패턴은 추천 한다.

  • uGUI를 사용한다면 이 패턴을 사용하면 굉장히 편리하다.
  • View를 위해 약간의 로직이 필요하다면 그것을 Presenter에 써도 좋다.
    • 데이터 형식의 반환 같은 것
  • Presenter의 역할은 "Model과 View를 연결한다" 이므로, 거기에 벗어나는 처리는 하지 않는다.
    • Presenter의 구현은 몇 줄 ~ 수십 줄 정도로 끝날 정도가 적당하다.

[역주]

어떻게 프로그래밍을 하느냐에 따라 달라질 것 같다.

실제 모델에서 비즈니스 로직 처리를 전부 완료하고, 값의 반환을 Presenter에 전달하는 방식으로 구현하거나, View 자체의 논리적인 변화가 너무 많아진다면 View를 위한 컴포넌트를 따로 만들어서 처리하는 방식등을 사용하여 구현해야 할 수 있다.

프로그래밍 편 완료

마지막으로

  • 떠오른 생각을 닥치는 대로 썻는데, 분량이 대단히 많아 졌다.
  • 조금이라도 도움이 되는 부분이 있으면 좋겠다.
  • 이제 두번 다시 네트워크 게임은 만들고 싶지 않다.

감사합니다.

· 약 1분
karais89

환경

  • macOS Catalina v10.15
  • Unity 2019.2.10f1
  • Github Desktop
  • Rider 2019.2
  • UniRx v7.1.0

원문 : https://qiita.com/toRisouP/items/00b8a5bb8e7b68e0686c

이 포스팅은 원문을 단순히 구글 번역을 하여 정리한 내용입니다. 일본어를 잘하시는 분은 원문을 보시는게 더 좋으실 것 같습니다.



과거에 쓴 포스트 목록

· 약 24분
karais89

환경

  • macOS Catalina v10.15
  • Unity 2019.2.10f1
  • Github Desktop
  • Rider 2019.2
  • UniRx v7.1.0

원문 : https://qiita.com/toRisouP/items/86fea641982e6e16dac6

이 포스팅은 원문을 단순히 구글 번역을 하여 정리한 내용입니다. 일본어를 잘하시는 분은 원문을 보시는게 더 좋으실 것 같습니다.

UniRx 입문 시리즈 목차는 이쪽


0. 이전 복습

지난번 OnNext, OnError, OnCompleted 및 IDisposable의 용도에 대해 설명하고 스트림의 수명 관리 방법에 대해서도 설명하였습니다.

이번은 "스트림 소스를 만드는 법"에 대해 간략히 설명해 드리고자 합니다.

1. 스트림의 소스 (메시지 게시자)는?

UniRx의 스트림은 다음의 3가지로 구성되어 있습니다.

  1. 메시지를 발행하는 소스가 되는 것 (Subject 등)
  2. 메시지 전파하는 오퍼레이터 (Where, Select 등)
  3. 메시지를 구독하는 것 (Subscribe)

특히 UniRx를 사용한지 얼마 안되는 사람들은 "1"의 스트림 소스를 준비하는 방법을 잘 모르실 것이라고 생각합니다. 이번에는 이 스트림의 발단이되는 스트림 소스를 만드는 방법을 소개하고 싶습니다.

2. 스트림 소스가 될 수 있는 목록

스트림 소스를 준비하는 방법은 여러 가지가 있습니다. UniRx에서 제공해주는 스트림 소스를 이용해도 좋으며, 스스로 스트림 소스를 만들 수도 있습니다.

UniRx를 이용할 경우 다음과 같은 방법의 스트림 소스를 제공하고 있습니다.

  • Subject 시리즈를 사용
  • ReactiveProperty 시리즈를 사용
  • 팩토리 메서드 시리즈를 사용
  • UniRx.Triggers 시리즈를 사용
  • 코루틴을 변환하여 사용
  • uGUI 이벤트를 변환하여 사용
  • 기타 UniRx에서 준비되어 있는 것을 사용

각각 순서대로 설명하겠습니다.

Subject 시리즈

제 1회부터 여러번 등장하고 있는 Subject이지만, 이것을 이용하는 패턴이 가장 기본형입니다.

직접 스트림을 만들어 자유롭게 이벤트를 발행하고 싶다고 생각했을때 이 Subject를 사용하면 일단은 문제 없을 것입니다.

그리고 이 Subject는 몇 가지 파생이 존재하고 각각 다른 행동을 취합니다. 용도에 따라 적절한 것을 사용하는 것이 좋습니다. 이번에는 테이블로 간략히 소개하고 있지만, 각 Subject의 자세한 설명은 다음번에 설명하겠습니다.

Subject기능
Subject<T> 가장 간단한 것.OnNext가 실행되면 값을 발행한다.
BehaviourSubject<T>마지막으로 발행 된 값을 캐쉬하고 나중에 Subscribe 될 때 그 캐시를 반환해 준다. 초기 값을 설정 할 수도 있다.
ReplaySubject<T>과거 모든 발행 된 값을 캐쉬하고 나중에 Subscribe 될 때 그 캐시를 모두 정리해 발행한다.
AsyncSubject<T>OnNext를 즉시 발행하지 않고 내부에 캐쉬하고 OnCompleted가 실행 된 시간에 마지막 OnNext 하나만 발행한다. Future 및 Promise 같은 것

AynscSubject는 말 그대로 Future 나 Promise 같은 것입니다. 비동기 처리를 하고 싶을때 이용할 수 있습니다.

역주: Future나 Promise는 다른 언어에서 존재하는 개념. C++에서 std에 Future, Promise api가 포함되어 있어 해당 기능을 이용하면 비동기 처리를 실행할 수 있다.

ReactiveProperty 시리즈

ReactiveProperty<T>

ReactiveProperty<T>는 변수에 Subject의 기능을 붙인 것입니다. (구현도 그런 느낌으로 되어 있습니다.)

변수를 정의하기 쉽게 되어 있고 알기 쉽기 때문에, 초심자에게 추천 합니다.

// int형의 ReactiveProperty
var rp = new ReactiveProperty<int>(10); // 초기값 지정 가능

// 일반적으로 대입하거나 값을 읽을 수 있다.
rp.Value = 20;
var currentValue = rp.Value;

// Subscribe 할 수 있다. (Subscribe시 현재 값도 발행된다)
rp.Subscribe(x => Debug.Log(x));

// 값을 다시 설정할때 OnNext 발행 된다.
rp.Value = 30;

실행결과

20
30

또한 ReactiveProperty는 인스펙터 뷰에 표시하여 이용할 수 있습니다. 이 경우 제네릭 버전이 아닌, 각각의 형태의 전용 ReactiveProeprtyProperty를 사용해야 합니다.

또한 ReactiveProperty는 다음번에 설명할 예정인 MV(R)P 패턴에서 그 진가를 발휘하게 됩니다. 그때 까지 확실하게 마스터 하세요!

using UniRx;
using UnityEngine;

public class TestReactiveProperty : MonoBehaviour
{
// int형의 ReactiveProperty (인스펙터 뷰에 나오는 버전)
[SerializeField]
private IntReactiveProperty playerHealth = new IntReactiveProperty(100);

private void Start() => playerHealth.Subscribe(x => Debug.Log(x));
}

또한, enum도 ReactiveProperty화 해서 인스펙터 뷰에 표시 할 수도 있지만, 이쪽은 좀 더 연구가 필요합니다. 여기에 대해서는 UniRx의 저장 neuecc씨가 블로그 쪽에서 설명하고 있기 때문에 그 쪽을 참고하시면 좋을 것입니다.

UniRx 4.8-경량 이벤트 훅과 uGUI연계에 의한 데이터 바인딩

ReactiveCollection

ReactiveCollection<T>는 ReactiveProperty와 같은 것이며, 상태의 변화를 알리는 기능이 내장된 List<T> 입니다.

ReactiveCollection은 보통의 List처럼 쓸 수 있는 데다 상태의 변화를 Subscribe 할 수 있도록 되어 있습니다. 준비되어 있는 이벤트는 다음과 같습니다.

  • 요소 추가
  • 요소의 제거
  • 요소 수의 변화
  • 요소 재정의
  • 요소의 이동
  • 목록 지우기
var collection = new ReactiveCollection<string>();

collection
.ObserveAdd()
.Subscribe(x =>
{
Debug.Log($"Add [{x.Index}] = {x.Value}");
});

collection
.ObserveRemove()
.Subscribe(x =>
{
Debug.Log($"Remove [{x.Index}] = {x.Value}");
});

collection.Add("Apple");
collection.Add("Baseball");
collection.Add("Cherry");
collection.Remove("Apple");

실행결과

Add [0] = Apple
Add [1] = Baseball
Add [2] = Cherry
Remove [0] = Apple

ReactiveDictionary<T1, T2>

ReactiveDictionary의 Dictionary 버전 입니다. ReactiveCollection과 대부분 행동이 동일하므로 생략합니다.

팩토리 메서드 시리즈

팩토리 메서드는 UniRx가 제공하는 스트림 소스 구축 메서드 군입니다.

Subject만으로는 표현할 수 없는 복잡한 스트림을 쉽게 만들 수 있는 경우가 있습니다. Unity에서 UniRx를 이용하는 경우는 팩토리 메서드를 사용할 수 있는 기회는 그리 없을지도 모르지만, 어딘가에서 도움이 될 수 있다고 생각하기 때문에 기억하는 것도 좋을 것 같습니다.

그러나 팩토리 메서드 수가 많기 때문에 이용 빈도가 높은 것만 소개 하겠습니다.

만약 모든 팩토리 메서드 방법을 알고 싶다면, ReactiveX의 Operators 항목을 참고하는 것이 좋을 것 같습니다.

ReactiveX Creating Observables 항목

Observable.Create

Observable.Create<T>는 자유롭게 값을 발행하는 스트림을 만들 수 있는 팩토리 메서드 입니다. 예를 들어, 일정한 절차에 의해 처리 호출 규칙을 이 팩토리 메서드 내부에 은폐시켜, 결과만을 스트림에서 추출하는 방법등이 있습니다.

Observable.Create는 인수 Func<IObserver<T>, IDisposable>(IObserver<T>를 받고 IDisposable를 반환하는 딜리게이트)를 인수에 취합니다. 실제로 사용법을 보여주는 것이 알기 쉬울 거라고 생각 됩니다.

Observable.Create<int>(observer =>
{
Debug.Log("Start");

for (int i = 0; i <= 100; i += 10)
{
observer.OnNext(i);
}

Debug.Log("Finished");
observer.OnCompleted();

return Disposable.Create(() =>
{
// 종료시 처리
Debug.Log("Dispose");
});
}).Subscribe(x => Debug.Log(x));

실행결과

Start
0
10
20
30
40
50
60
70
80
90
100
Finished
Disposable

Observable.Start

Observable.Start는 주어진 블록을 다른 스레드에서 실행하여 결과를 1개만 발급하는 팩토리 메서드 입니다. 비동기로 무엇인가를 처리를 하고 결과가 나오면 통지를 원할때 사용할 수 있습니다.

// 주어진 블록 내부를 다른 스레드에서 실행
Observable.Start(() =>
{
// google의 메인 페이지를 http를 통해 get 한다.
var req = (HttpWebRequest) WebRequest.Create("https://google.com");
var res = (HttpWebResponse) req.GetResponse();
using (var reader = new StreamReader(res.GetResponseStream()))
{
return reader.ReadToEnd();
}
})
.ObserveOnMainThread() // 메시지를 다른 스레드에서 Unity 메인 스레드로 전환
.Subscribe(x => Debug.Log(x));

하나 주의할 점이 있습니다. Observable.Start 처리를 다른 스레드에서 실행하고 그 쓰레드에서 그대로 Subscribe 내 함수를 실행합니다. 이것은 스레드로부터 안전하지 않은 Unity에서 문제를 일으킬 수 있으므로 주의해야 합니다.

만약 메시지를 다른 스레드에서 메인 스레드로 전환하고자 하는 경우 ObserveOnMainThread의 오퍼레이터를 이용합시다. 이 오퍼레이터를 끼우는 것만으로, 이 오퍼레이터 이 후 Unity 메인 스레드에서 실행되도록 변환 합니다.

Observable.Timer/TimerFrame

Observable.Timer은 일정 시간 후에 메시지를 발행하는 간단한 팩토리 메서드 입니다.

실제 시간을 지정하는 경우 Timer를 사용하고 Unity의 프레임 수로 지정하는 경우 TimerFrame을 이용합시다.

Timer, TimerFrame은 인수에 따라 행동이 달라집니다. 1개 밖에 지정하지 않으면 OnShot 동작으로 종료하고 2개 지정한 경우 주기적으로 메시지를 발행하는 행동입니다. 또한 스케줄러를 지정하여 실행하는 스레드를 지정할 수 있습니다.

또한 비슷한 팩토리 메서드 인 Observable.Interval/IntervalFrame도 존재합니다. 이것은 Timer/TimerFrame의 2개의 인수를 지정하는 경우의 생략 버전 같은 것으로 생각 하시면 됩니다. Interva/IntervalFrame은 타이머를 시작할 때까지의 시간 (첫번째 인수)를 지정할 수 없게 되어 있습니다.

// 5초 후에 메시지 발행하고 종료
Observable.Timer(TimeSpan.FromSeconds(5))
.Subscribe(_ => Debug.Log("5초 경과했습니다."));

// 5초 후 메시지 발행 후 1초 간격으로 계속 발행
// 스스로 정지시키지 않는 한 계속 움직인다.
Observable.Timer(TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(1))
.Subscribe(_ => Debug.Log("주기적으로 수행되고 있습니다."))
.AddTo(gameObject);

Timer, TimerFrame은 정기적으로 실행을 하고자 할때는 Dispose의 작동을 기억해 주시기 바랍니다. 멈추는 것을 잊지 않고 방치하면 메모리 누수와 NullReferenceException의 원인이 됩니다.

역주:

해당 코드에서 정기적으로 발생하는 Timer의 경우 뒤에 AddTo 메서드를 붙여, 게임오브젝트가 제거 될때 자동으로 Dispose가 호출되게 됩니다.

UniRx.Triggers 시리즈

UniRx.Triggers는 using UniRx.Triggers; 를 사용하는 스트림 소스입니다. Unity 콜백 이벤트를 UniRx의 IObservable로 변환하여 제공 해주고 있습니다. UniRx에서는 이것이 가장 중요하고 유용하다고 생각합니다.

Triggers는 수가 매우 많기 때문에 다 소개 할수 없으므로, GitHub의 wiki를 참고하십시오.

GitHub - UniRx.Triggers

Unity가 제공하는 대부분의 콜백 이벤트를 스트림으로써 취득 가능하게 되어 있으며 GameObject가 Destroy 될때 자동으로 OnCompleted를 발급 해주는 구조로 되어 있기 때문에, 수명 관리도 걱정 없습니다.

using UniRx;
using UnityEngine;
using UniRx.Triggers; // 필수 추가

// <summary>
// WarpZone (라는 이름의 IsTrigger인 Collider가 붙은 영역)에
// 들어왔을때 부유하는 스크림트 (임의)
// </summary>
public class TriggersSample : MonoBehaviour
{
private void Start()
{
bool isForceEnabled = true;
var rb = GetComponent<Rigidbody>();

// 플래그가 유요한 동안 위쪽에 힘을 가한다.
this.FixedUpdateAsObservable()
.Where(_ => isForceEnabled)
.Subscribe(_ => rb.AddForce(Vector3.up * 20));

// WarpZone에 침입하면 플래그를 활성화 한다.
this.OnTriggerEnterAsObservable()
.Where(x => x.gameObject.CompareTag("WarpZone"))
.Subscribe(_ => isForceEnabled = true);

// WarpZone에 나오면 플래그를 해제 한다.
this.OnTriggerExitAsObservable()
.Where(x => x.gameObject.CompareTag("WarpZone"))
.Subscribe(_ => isForceEnabled = false);
}
}

Triggers를 사용하여 Unity 콜백을 스트림으로 변환하면 모든 것을 Awake/Start에 작성할 수 있습니다. 이것의 이점은 다음 번에 자세히 설명하겠습니다.

코루틴에서 변환

사실 Unity의 코루틴과 UniRx는 아주 궁합이 잘 맞습니다. IObservable 및 코루틴은 서로 변환하여 이용하는 것이 가능 합니다.

코루틴에서 IObservable의 변환은 Observable.FromCoroutine을 이용하여 수행 할 수 있습니다. 오퍼레이터 체인으로 복잡한 스트림을 구축하는 것보다 코루틴을 사용해 절차적으로 쓰는 경우가 더 심플하고 알기 쉬운 경우도 존재 합니다. 코루틴은 악으로 단정짓지 말고 차라리 코루틴과 UniRx를 함께 사용하는 것이 편리하다는 것을 기억하세요.

UniRx와 코루틴의 결합에 대한 설명은 다음번에 자세히 설명하겠습니다. 이번에는 간단한 예제만 소개하는 것으로 마치겠습니다.

using System;
using System.Collections;
using UniRx;
using UnityEngine;

public class Example23_Timer : MonoBehaviour
{
// <summary>
// 일시 정지 플래그
// </summary>
public bool IsPaused { get; private set; }

private void Start()
{
// 60초 카운트하는 스트림을 코루틴에서 만든다.
Observable.FromCoroutine<int>(observer => TimerCoroutine(observer, 60))
.Subscribe(t => Debug.Log());
}

// <summary>
// 초기 값에서 0까지 카운트하는 코루틴
// 그러나 IsPaused 플래그가 유요한 경우는 카운트 중지
// </summary>
IEnumerator TimerCoroutine(IObserver<int> observer, int initializeTime)
{
var current = initializeTime;
while (current > 0)
{
if (!IsPaused)
{
observer.OnNext(current--);
}
yield return new WaitForSeconds(1);
}
observer.OnNext(0);
observer.OnCompleted();
}
}

uGUI 이벤트에서 변환

UniRx는 uGUI와도 궁합이 좋고, ReactiveProperty와 결합하여 View와 Model의 관계를 굉장히 명확하게 구현할 수 있습니다. (MV(R)P 패턴이라고 불립니다.)

이번에는 MV(R)P 패턴은 설명 하지 않고 uGUI 이벤트에서 변환하는 방법만 소개 하겠습니다.

라고 해도 크게 소개할 것은 없고, UniRx를 using하고 있으면 uGUI 컴포넌트의 uGUI 이벤트로서 그대로 사용할 수 있도록 되어 있습니다.

using UniRx;
using UnityEngine;
using UnityEngine.UI;

public class Example23_uGUI : MonoBehaviour
{
// 인스펙터에서 설정
[SerializeField] private Button button;
[SerializeField] private InputField inputField;
[SerializeField] private Slider slider;

private void Start()
{
// uGUI의 기본 Unity 이벤트의 이름을 한 Observable이 준비되어 있다.
button.OnClickAsObservable().Subscribe(_ =>
{
Debug.Log("button OnClick!");
});

inputField.OnValueChangedAsObservable().Subscribe(str =>
{
Debug.Log("inputField OnValueChanged : " + str);
});
inputField.OnEndEditAsObservable().Subscribe(str =>
{
Debug.Log("inputField OnEndEdit : " + str);
});

slider.OnValueChangedAsObservable().Subscribe(val =>
{
Debug.Log("slider value changed : " + val);
});

// ----------

// 또한 이러한 방법도 있다.
inputField.onValueChanged.AsObservable().Subscribe();

// 이 두 기법의 차이는 Subscribe시 현재 값의 초기 값의 발행 여부이다
// Subscribe시 초기 값이 필요한 경우는 전자를 사용하면 된다.
inputField.OnValueChangedAsObservable(); // 초기값이 있다.
inputField.onValueChanged.AsObservable(); // 초기값이 없다.
}
}

기타

UniRx는 이외에도 편리한 스트림 소스를 제공 해주고 있습니다. 그 중 일부를 소개하겠습니다.

ObservableWWW

ObservableWWW는 Unity의 WWW를 스트림으로 처리 할 수 있도록 래핑 해 준 것입니다. 호출하는 것으로 UniRx는 코루틴을 실행해 WWW를 처리하고 결과만 알려줍니다.

ObservableWWW.Get("https://google.com")
.Subscribe(x => Debug.Log(x));

(UniRx는 내부에 코루틴을 가지고 있습니다. 그 실체가 되는 GameObject는 MainThreadDispacher 라는 이름으로 씬에 존재합니다. 이 MainThreadDispatcher를 멈추면 UniRx가 올바르게 동작하지 않게 되므로 이 GameObject를 손보는 것은 피하는 것이 현명합니다.)

역주:

Unity 2018.3 이상 버전부터는 ObservableWWW의 사용을 하지 않는 것을 권장하고 있습니다. 그 대신에 유니티에 새로 추가된 UnityWebRequest를 사용하라고 권장하고 있습니다. 실제 편안한 사용을 위해서는 UniTask 라이브러리와 같이 사용해 C#의 Task 처럼 사용을 하거나 Task를 Observable로 변환하여 사용하면 될 것으로 보인다. 자세한 내용은 UniRx.Async를 참고

Observable.NextFrame

이름 그대로 다음 프레임으로 메시지를 발행 해주는 스트림을 만들 수 있습니다. 메시지의 발행 타이밍은 Update가 타이밍이 아닌 코루틴 타이밍 이므로, 실행 타이밍이 매우 중요한 경우에는 주의가 필요합니다.

참고 : Unity 문서 이벤트 함수의 실행 순서

Observable.NextFrame()
.Subscribe(_ => Debug.Log("다음 프레임에서 실행됩니다."));

역주: 업데이트 타이밍은 다음 3가지로 조절 가능하며, 기본 값은 Update 이다.

  • Update (yield return null)
  • FixedUpdate (yield return new WaitForFixedUpdate())
  • EndOfFrame (yield return new WaitForEndOfFrame())

Observable.EveryUpdate

Observable.EveryUpdate는 매 Update 타이밍을 알려주는 스트림 소스 입니다. UniRx.Triggers의 UpdateAsObservable 과 비슷하지만 이쪽은 GameObject에 붙어 Destroy시 OnCompleted가 실행되는 반면 Observable.EveryUpdate스스로 중지하지 않는 한 씬을 거쳐도 계속 움직이는 스트림 입니다. FPS 카운터와 같은 어떤 씬에서도 계속 같은 스트림을 구축해야 될때 사용하면 좋습니다.

참고: UniRx에서 FPS 카운터를 만들어 보자.

ObservableEveryValueChanged

ObservableEveryValueChanged는 스트림 소스 중에서도 이색적인 존재이며, class 그 자체(?)의 확장 메서드로 정의되어 있습니다. 기능으로는 모든 객체의 파라미터를 매 프레임 모니터링하고 변화가 있었을 때에 통지하는 스트림을 생성 할 수 있습니다.

var characterController = GetComponent<CharacterController>();

// CharacterController의 isGrounded를 감시
// false -> true가 되면 로그 출력
characterController
.ObserveEveryValueChanged(c => c.isGrounded)
.Where(x => x)
.Subscribe(_ => Debug.Log("착지!"))
.AddTo(gameObject);

// ↑ 코드는 ↓와 거의 동의어
Observable.EveryUpdate()
.Select(_ => characterController.isGrounded)
.DistinctUntilChanged()
.Where(x => x)
.Subscribe(_ => Debug.Log("착지!"))
.AddTo(gameObject);

// ObserveEveryValueChanged는
// EveryUpdate + Select + DistinctUntilChanged
// 의 축약 버전에 속한다.

3. 정리

스트림 근원(소스)를 만드는 방법은 여러가지가 있다.

  • Subject 시리즈를 사용
  • ReactiveProperty 시리즈를 사용
  • 팩토리 메서드 시리즈를 사용
  • UniRx.Triggers 시리즈를 사용
  • 코루틴을 변환하여 사용
  • uGUI 이벤트를 변환하여 사용
  • 기타 UniRx에서 준비되어 있는 것을 사용

"UniRx.Triggers", "ReactiveProperty", "uGUI에서 변환"이 개인적으로 최우선으로 기억해야 될 것이라고 생각합니다. 이 3가지만 기억한다면 우선 UniRx를 사용한 개발에 80% 정도는 어떻게든 된다고 생각합니다.

· 약 22분
karais89

환경

  • macOS Catalina v10.15
  • Unity 2019.2.9f1
  • Github Desktop
  • Rider 2019.2
  • UniRx v7.1.0

원문 : https://qiita.com/toRisouP/items/851087b4c990d87641e6

이 포스팅은 원문을 단순히 구글 번역을 하여 정리한 내용입니다. 일본어를 잘하시는 분은 원문을 보시는게 더 좋으실 것 같습니다.

UniRx 입문 시리즈 목차는 이쪽


0. 이전 복습

지난번 IObserver 인터페이스는 다음과 같이 정의된다고 설명했습니다.

using System;

namespace UniRx
{
public interface IObserver<T>
{
void OnCompleted();
void OnError(Exception error);
void OnNext(T value);
}
}

지난번에는 설명의 편의상 OnNext만 설명 했습니다.

이번에는 생략했던 "OnError", "OnCompleted" 및 "Dispose"에 대해 설명하겠습니다.

1. "OnNext", "OnError", "OnCompleted"

UniRx에서 발행되는 메시지는 모두 이 3가지 중 어느 하나가 되며, 다음과 같은 용도로 이용되고 있습니다.

  • OnNext: 통상 이벤트가 발행되었을 때 통지되는 메시지
  • OnError: 스트림 처리 중 예외가 발생했음을 통지한다.
  • OnCompleted: 스트림이 종료되었음을 통지한다.

"OnNext"메시지

OnNext는 UniRx에서 가장 많이 사용되는 메시지이며, 보통 "이벤트 통지" (EventArgs 포함)을 나타냅니다.

가장 많이 이용되는 메시지이며, 사용법에 따라서는 이 OnNext 메시지만을 기억하고 있어도 문제가 없는 경우도 많습니다.

예 1. 정수값 통지

var subject = new Subject<int>();

subject.Subscribe(x => Debug.Log(x));
subject.OnNext(1);
subject.OnNext(2);
subject.OnNext(3);
subject.OnCompleted();

실행결과

1
2
3

예 1은 간단하게 정수를 통지하고 구독 측에서는 Debug.Log에 표시하는 단순한 샘플 코드 입니다.

예 2. "의미"없는 값을 통지

var subject = new Subject<Unit>();

subject.Subscribe(x => Debug.Log(x));

// Unit 형은 그 자체는 별 의미가없다.
// 메시지의 내용에 의미가 아니라 이벤트 알림 타이밍이 중요한 순간에 사용할 수 있다.
subject.OnNext(Unit.Default);

실행결과

()

예 2는 Unit형 이라는 특수한 형태를 발행하고 있습니다.

이 형태는 "메시지의 내용물에 의미는 없다"라는 표현을 할 때 사용합니다.

이것은 "이벤트가 발생된 타이밍이 중요하며, OnNext 메시지 내용은 상관 없다"라는 경우에 사용할 수 있습니다.

예를 들어, "씬의 초기화 완료", "플레이어 사망"등에서 사용할 수 있습니다.

예 3. 씬의 초기화 완료 후 Unit형 통지

public class GameInitializer : MonoBehaviour
{
// Unit형 사용
private Subject<Unit> initializedSubject = new Subject<Unit>();

public IObservable<Unit> OnInitializedAsync => initializedSubject;

private void Start()
{
// 초기화 시작
StartCoroutine(GameInitializeCoroutine());

OnInitializedAsync.Subscribe(_ => { Debug.Log("초기화 완료"); });
}

private IEnumerator GameInitializeCoroutine()
{
/*
* 초기화 처리
*
* WWW 통신이나 개체 인스턴스화 등
* 시간이 걸리고 무거운 처리를 여기에서 한다고 가정
*/
yield return null;

// 초기화 완료 통지
initializedSubject.OnNext(Unit.Default); // 타이밍이 중요한 통지이므로 Unit로도 충분하다.
initializedSubject.OnCompleted();
}
}

코루틴으로 게임의 초기화를 수행하고 처리가 완료되면 이벤트를 발행해 통지하는 클래스 구현 예 입니다.

이러한 이벤트는 이벤트의 내용물 값은 무엇이든 상관없는 상황에서 Unit형을 사용하는 경우가 많습니다.

(또한 예3에서는 Subject를 사용했지만 이 경우는 AsyncSubject가 적합 할지도 모릅니다. AsyncSubject에 대해서는 다음에 설명하겠습니다.)

"OnError" 메시지

OnError 메시지는 이름 그대로 예외가 스트림 도중에 발생했을 때에 통지되는 메시지로 되어 있습니다.

OnError 메시지 스트림 도중에 catch하여 처리하거나 그대로 Subscribe 메서드에 도달시켜 처리 할 수 있습니다. 만약 OnError 메시지가 Subscribe까지 도달 한 경우, 그 스트림 구독은 종료되고 파기 되어 버립니다.

예 4. 도중에 발생한 예외를 Subscribe로 받는다

var stringSubject = new Subject<string>();

// 문자열을 스트림 중간에서 정수로 변환
stringSubject
.Select(str => int.Parse(str)) // 숫자를 표현하는 문자열이 아닌 경우는 예외가 나온다
.Subscribe(
x => Debug.Log("성공:" + x), // OnNext
ex => Debug.Log("예외가 발생:" + ex) // OnError
);

stringSubject.OnNext("1");
stringSubject.OnNext("2");
stringSubject.OnNext("Hello"); // 이 메시지에서 예외가 발생 한다.
stringSubject.OnNext("4");
stringSubject.OnCompleted();

실행결과

성공 : 1
성공 : 2
예외가 발생 : System.FormatException : Input string was not in the correct format

※ Subscribe의 오버로드 중 Subscribe(OnNext, OnError)를 받는 메서드를 이용하고 있습니다

예 4는 OnNext로 보내져 온 문자열을 Select 오퍼레이터(값의 변환)에서 int로 캐스팅해서 표시하는 스트림을 사용한 예입니다.

이와 같이 스트림 중간에 예외가 발생했다면 OnError 메시지가 발행되고 Subscribe에게 통지가 오고 있는 것을 알 수 있습니다.

또한 OnError을 받은 후 OnNext("4")는 처리가 되지 않습니다. 이처럼 "OnError를 Subscribe가 감지하면 스트림 구독을 중단 한다"는 것을 기억하십시오.

예 5. 도중에 예외가 발생하면 다시 구독하기

var stringSubject = new Subject<string>();

// 문자열을 스트림 중간에서 정수로 변환
stringSubject
.Select(str => int.Parse(str))
.OnErrorRetry((FormatException ex) => // 예외의 형식으로 필터링 가능
{
Debug.Log("예외가 발생하여 다시 구독 합니다");
})
.Subscribe(
x => Debug.Log("성공:" + x), // OnNext
ex => Debug.Log("예외가 발생:" + ex) // OnError
);

stringSubject.OnNext("1");
stringSubject.OnNext("2");
stringSubject.OnNext("Hello");
stringSubject.OnNext("4");
stringSubject.OnNext("5");
stringSubject.OnCompleted();

실행결과

성공 : 1
성공 : 2
예외가 발생하여 다시 구독합니다
성공 : 4
성공 : 5

예 5는 도중에 예외가 발생했을 경우 OnErrorRetr로 스트림을 재 구축하고 구독을 계속하고 있습니다.

OnErrorRetry는 OnError가 특정 예외인 경우에 다시 Subscribe를 시도해주는 예외 핸들링 오퍼레이터 입니다. (여기서 말하는 Subscribe를 다시 시작한다는 것은 Subject에 IObserver을 다시 등록하러 간다는 뜻입니다)

하고 싶은 일오퍼레이터비고
OnError가 오면 다시 Subscribe 하고 싶다.Retry
OnError를 받아 에러 처리를 하고 다른 스트림으로 전환한다.Catch
OnError을 받아 에러 처리를 한 후, OnError을 무시하고 OnCompleted로 대체하고 싶다.CatchIgnore
OnError가 오면 에러 처리를 한 후, Subscribe를 다시 하고 싶다. (시간 지정 가능)OnErrorRetry

"OnCompleted" 메시지

OnCompleted는 "스트림이 완료되었기 때문에 이후 메시지를 발행하지 않겠다"라는 것을 통지하는 메시지입니다.

만약 OnCompleted 메시지가 Subscribe까지 도달한 경우 OnError과 마찬가지로 그 스트림의 구독은 종료되고 파기됩니다. 이 성질을 이용하여 스트림에게 OnCompleted를 적절히 발행하여 올리면 정리하여 구독 종료를 실행할 수 있기 때문에 스트림 뒷정리를 할 경우에는 이 메시지를 발행하도록 합시다.

또한, 한번 OnCompleted를 발행한 Subject는 재이용이 불가능합니다. Subscribe 해도 금방 OnCompleted가 돌아오게 됩니다.

예 6. OnCompleted를 감지

var subject = new Subject<int>();
subject.Subscribe(
x => Debug.Log(x),
() => Debug.Log("OnCompleted")
);
subject.OnNext(1);
subject.OnNext(2);
subject.OnCompleted();

실행결과

1
2
OnCompleted

※ Subscribe의 오버로드 중 Subscribe(OnNext, OnCompleted)을 받는 메서드를 이용하고 있습니다.

예 6과 같이 Subscribe에 OnCompleted를 받은 오버로드를 사용하여 OnCompleted를 감지할 수 있습니다.

Subscribe의 오버로드

UniRx 입문 1 에서 Subscribe에는 여러 오버로드가 존재한다고 설명했습니다.

실제로는 다음 조합의 오버로드가 준비되어 있고, 이용하고 싶은 메시지에 맞게 선택하면 좋을 것 같습니다.

  • Subscribe (IObserber observer) ----------- 기본형
  • Subscribe () ------------------------------ 모든 메시지를 무시
  • Subscribe (Action onNext) ---------------- OnNext 만
  • Subscribe (Action onNext, Action onError) ---- OnNext + OnError
  • Subscribe (Action onNext, Action onCompleted) - OnNext + OnCompleted
  • Subscribe (Action onNext, Action onError, Action onCompleted) - 전부

2. 스트림의 구독 종료 (Dispose)

이어서 IObservable의 "IDisposable"를 설명 하겠습니다.

public interface IObservable<T>
{
IDisposable Subscribe(IObserver<T> observer);
}

IDisposable는 C#에서 제공하는 인터페이스이며, "리소스 해제"를 실시할 수 있도록 하기 위한 메서드 "Dispose()"를 단지 1개 가지고 있는 인터페이스 입니다.

Subscribe의 반환값이 IDisposable로 이라는 것은 즉 Subscribe 메서드가 돌려주는 IDisposable의 Dispose를 실행하면 스트림의 구독을 종료 할수 있다는 것이 됩니다.

예 7. Dispose() 스트림의 구독 종료

var subject = new Subject<int>();

// IDispose 저장
var disposable = subject.Subscribe(x => Debug.Log(x), () => Debug.Log("OnCompleted"));

subject.OnNext(1);
subject.OnNext(2);

// 구독종
disposable.Dispose();

subject.OnNext(3);
subject.OnCompleted();

실행결과

1
2

예 7은 구독을 Dispose를 호출하여 도중에 중지하는 예입니다.

따라서 Dispose를 호출하여 구독을 언제라도 중단 할 수 있습니다.

여기서 주의해야 할 것은 Dispose()를 실행해서 구독이 중단되도 OnCompleted가 발행되는 것은 아니다 라는 점입니다. 구독 중단 처리를 OnCompleted에 사용하는 경우, Dispose에서 정지시켜 버리면 실행되지 않으므로 주의하시기 바랍니다.

예 8. 특정 스트림만 수신 거부

var subject = new Subject<int>();

// IDispose 저장
var disposable1 = subject.Subscribe(x => Debug.Log("스트림1:" + x), () => Debug.Log("OnCompleted"));
var disposable2 = subject.Subscribe(x => Debug.Log("스트림2:" + x), () => Debug.Log("OnCompleted"));
subject.OnNext(1);
subject.OnNext(2);

// 스트림1만 구독종료
disposable1.Dispose();

subject.OnNext(3);
subject.OnCompleted();

실행결과

스트림1 : 1
스트림2 : 1
스트림1 : 2
스트림2 : 2
스트림2 : 3
2 : OnCompleted

예 8은 특정 Subscribe의 Dispose를 호출하여 그 구독만 중지시키고 있는 예입니다.

OnCompleted를 실행하면 모든 스트림을 구독 종료하게되지만 Dispose를 사용하면 일부 스트림만 종료시킬 수 있습니다.

3. 스트림의 수명과 Subscribe 종료 타이밍

UniRx를 사용하는데 있어서 특히 조심하지 않으면 안되는 것이 스트림의 라이프 사이클입니다.

객체가 자주 출현과 삭제를 반복하는 Unity에서는, 특히 이것을 의식하지 않으면 퍼포먼스 저하나 에러에 의한 오동작을 일으키게 됩니다.

'스트림'의 실체는 누가 가지고 있는가?

스트림의 수명 관리를 하는데, "그 스트림은 누구의 소요인가?"를 의식할 필요가 있습니다.

기본적으로, 스트림의 실체는 "Subject"이며 Subject가 파기되면 스트림도 파기 됩니다.

이전에도 설명했지만, "Subscribe"란 Subject에 함수를 등록하는 과정이었습니다.즉, 스트림의 실체는 Subject가 내부에 유지하는 "호출 함수 목록( 및 그 함수에 연관된 메소드 체인)"로, Subject가 스트림을 관리하는 것입니다.

Subject가 파기되면 스트림도 모두 파기됩니다. 반대로 말하면, Subject가 남아있는 한 스트림은 계속 실행 된다 인것입니다. 스트림이 참조하고 있는 객체를 스트림보다 먼저 버리고 방치해 버리면 뒤에서 스트림이 계속 동작 상태가 되기 때문에 성능 저하를 일으키거나 메모리 누수가 발생하거나 NullReferenceException을 발생시켜 게임을 정지시킬 가능성도 있습니다.

스트림의 수명 관리는 세심한 주의를 기울여야 됩니다. 사용이 끝나면 반드시 Dispose를 호출하거나 OnCompleted를 발행하는 습관을 들입시다.

예 9. 플레이어의 좌표를 이벤트 알림으로 갱신

요약

액션 게임을 상정하고 이런 경우를 생각해 봅시다.

  • 플레이어를 조작 할 수 있다.
  • 타이머로 시간을 카운트 하고 있다.
  • 타이머가 0이 되었을 때 플레이어를 초기 좌표로 되돌린다.
  • 플레이어는 화면 밖으로 나오면 사망(소멸)한다.

구현

using System;
using System.Collections;
using UniRx;
using UnityEngine;

/// <summary>
/// 카운트 다운하고 그때 값을 통지한다.
/// 3,2,1,0,(OnCompleted) 이런식으로 이벤트가 날라간다.
/// </summary>
public class TimeCounter : MonoBehaviour
{
[SerializeField] private int TimeLeft = 3;

// 타이머 스트림의 실체는 이 Subject
private Subject<int> timerSubject = new Subject<int>();

public IObservable<int> OnTimeChanged => timerSubject;

private void Start()
{
StartCoroutine(TimerCoroutine());

// 현재의 카운트를 표시
timerSubject.Subscribe(x => Debug.Log(x));
}

private IEnumerator TimerCoroutine()
{
yield return null;

var time = TimeLeft;
while (time >= 0)
{
timerSubject.OnNext(time--);
yield return new WaitForSeconds(1);
}
timerSubject.OnCompleted();
}
}
using UniRx;
using UnityEngine;

// 플레이어 이동 처리
// 타이머가 0이되면 초기 좌표로 돌린다.
public class PlayerMover : MonoBehaviour
{
[SerializeField] private TimeCounter _timeCounter;
private float _moveSpeed = 10.0f;

private void Start()
{
// 타이머 구독
_timeCounter.OnTimeChanged
.Where(x => x == 0) // 타이머가 0이 되었을 때만 실행
.Subscribe(_ =>
{
// 타이머가 0이되면 초기 좌표로 돌린다
transform.position = Vector3.zero;
});
}

private void Update()
{
// 오른쪽 화살표를 누르고 있는 동안 이동
if (Input.GetKey(KeyCode.RightArrow))
{
transform.position += new Vector3(1, 0, 0) * _moveSpeed * Time.deltaTime;
}

// 화면 밖으로 나오면 제거
if (transform.position.x > 10)
{
Debug.Log("화면 밖에 나왔다!");
Destroy(gameObject);
}
}
}

실행 결과 (타이머 0에서 플레이어가 생존 한 경우)

타이머 0이 되었을 때 플레이어의 좌표가 제대로 초기 위치로 이동하게 됩니다.

이와 같이 플레이어가 생존한 경우는 이 코드가 제대로 실행됩니다.

실행 결과 (타이머 0에서 플레이어가 제거 된 경우)

타이머 0의 시점에서 플레이어가 제거 된 경우 이 코드에서는 MissingReferenceException 예외가 발생하고 있습니다.

즉, 이 코드는 타이머 0 시점에서 플레이어가 소멸한 경우 이상이 있다는 것을 알 수 있습니다.

원인

원인은 PlayerMover 이 부분에 있습니다.

private void Start()
{
// 타이머 구독
_timeCounter.OnTimeChanged
.Where(x => x == 0) // 타이머가 0이 되었을 때만 실행
.Subscribe(_ =>
{
// 타이머가 0이되면 초기 좌표로 돌린다
transform.position = Vector3.zero;
});
}

아까 말한대로 스트림의 실체는 Subject가 보관 유지하고 있습니다.

PlayerMover가 삭제 된다해도 TimeCounter.timerSubjec가 transform.position = Vector3.zero;를 호출 하게 됩니다.

그리고 Player의 transform에 액세스하려고 하고 transform을 가져오는데 실패 해서 MissingReferenceException 가 발생해 버리는게 예외의 원인입니다.

이와 같이 스트림의 수명과 객체의 수명이 일치하지 않는 경우, 스트림을 제대로 제거 하지 않는 경우 에러의 원인이 되어 버립니다.

대책

대책은 단순히 Player의 GameObject가 파기되면 스트림의 구독을 중지하면 된다 입니다.

구현 방법에는 여러 가지가 있지만, 이번에는 가장 간단한 AddTo의 사용 예제를 살펴 보도록 합니다.

private void Start()
{
// 타이머 구독
_timeCounter.OnTimeChanged
.Where(x => x == 0) // 타이머가 0이 되었을 때만 실행
.Subscribe(_ =>
{
// 타이머가 0이되면 초기 좌표로 돌린다
transform.position = Vector3.zero;
}).AddTo(gameObject); // 지정된 gameObject가 파기되면 Dispose 한다.
}

UniRx에는 AddTo 라는 메서드가 준비되어 있으며, Subscribe의 뒤에 AddTo(gameObject)라고 적음으로써 지정된 GameObject가 Destroy되면 자동으로 Dispose를 호출하도록 설정할 수 있습니다.

이렇게함으로써 Player가 제거되면 동시에 스트림의 구독을 중지하므로 조금 같은 예외가 발생하지 않습니다.

화면을 나가서 객체가 삭제 되어도 예외가 발생하지 않게 되었다.

4.정리

메시지의 종류는 3 종류가 존재한다.

  • OnNext: 보통 이벤트가 발생했을때 알림 메시지
  • OnError: 스트림을 처리하는 동안 예외가 발생한 경우 통지한다.
  • OnCompleted: 스트림이 종료되었음을 알린다.

스트림의 구독을 중단하는 패턴은 3가지가 있다.

  • Subscribe가 OnCompleted를 감지
  • Subscribe가 OnError를 감지
  • Subscribe가 반환한 IDisposable의 Dispose()를 호출

스트림의 수명과 객체의 수명 관계는 항상 의식할 필요가 있다.

  • 객체를 지웠지만, 스트림은 살아 남은 상태는 절대적으로 피해야 한다.
  • 스트림을 사용한 후에 Dispose를 호출하거나 OnCompleted를 발행하는 버릇을 붙이자.

· 약 26분
karais89

환경

  • macOS Catalina v10.15
  • Unity 2019.2.9f1
  • Github Desktop
  • Rider 2019.2
  • UniRx v7.1.0

원문 : https://qiita.com/toRisouP/items/2f1643e344c741dd94f8

이 포스팅은 원문을 단순히 구글 번역을 하여 정리한 내용입니다. 일본어를 잘하시는 분은 원문을 보시는게 더 좋으실 것 같습니다.

UniRx 입문 시리즈 목차는 이쪽


서문

나는 과거에 UniRx에 대한 포스트를 산발적으로 쓰고 있었지만, 체계적으로 학습 할 수 있는 형태는 아니었습니다.

그래서 처음부터 읽어 가면서 체계적으로 UniRx를 학습 할 수 있는 자료를 만들려고 본 포스트를 쓰기로 했습니다.

(여유가 있을 때 작성되기 때문에 부정기 연재가 되지 않을까 생각합니다만 작 부탁드립니다)

0. 소개 "UniRx 란?"

UniRx는 neuecc 님이 작성한 Reactive Extensions for Unity 라이브러리 입니다.

Reactive Extensions (이하 Rx)는 요점만 말하면 다음과 같은 라이브러리 입니다.

  • MiscrosoftResearch가 개발하고 있던 C#용 비동기 처리를 위한 라이브러리
  • 디자인 패턴 중 하나인 Observer 패턴을 기반으로 설계되어 있다.
  • 시간에 관련된 처리 및 실행 타이밍이 중요한 곳에서 처리를 쉽게 작성할 수 있도록 되어 있다.
  • 완성도가 높고 Java, JavaScript, Swift 등 다양한 언어로 포팅되어 있다.

UniRx는 Rx를 기반으로 Unity에 이식된 라이브러리이며, 본가 .NET Rx에 비해 다음과 같은 차이가 있습니다.

  • Unity C#에 최적화되어 만들어져있다.
  • Unity 개발에 유용한 기능이나 오퍼레이터가 추가적으로 구현되어 있다.
  • ReactiveProperty 등이 추가되어 있다.
  • 철저하게 성능 튜닝이 진행되어, 원래 .NET Rx보다 메모리 퍼포먼스가 더 좋다.

처음 이 포스트를 읽는 분들에게는 위의 설명으로는 잘 이해되지 않을 것 같기 때문에, 여기는 대략적으로 이런 특징이 있구나 넘기고, 구체적인 설명에 들어가겠습니다.

1. "event와 UniRx"

event

여러분은 C# 표준 기능의 하나인 event를 이용한 적이 있으십니까?

event는 어떤 타이밍에서 메시지를 확인하고 다른 위치에 쓴 처리를 실행시킬 수 있는 기능입니다.

event 예 (event를 발행하는 측)

using System.Collections;
using UnityEngine;

/// <summary>
/// 100에서 카운트 다운 값을 보고하는 샘플
/// </summary>
public class TimeCounter : MonoBehaviour
{
/// <summary>
/// 이벤ㅌ 핸들러 (이벤트 메시지의 형식 정의)
/// </summary>
public delegate void TimerEventHandler(int time);

/// <summary>
/// 이벤트
/// </summary>
public event TimerEventHandler OnTimeChanged;

private void Start()
{
// 타이머 시작
StartCoroutine(TimerCoroutine());
}

private IEnumerator TimerCoroutine()
{
// 100에서 카운트 다운
var time = 100;
while (time > 0)
{
time--;

// 이벤트 알림
OnTimeChanged(time);

// 1초 기다리는
yield return new WaitForSeconds(1);
}
}
}

event 예 (event를받는 측)

using UnityEngine;
using UnityEngine.UI;

public class TimerView : MonoBehaviour
{
// 각 인스턴스는 인스펙터에서 설정
[SerializeField] private TimeCounter timeCounter;
[SerializeField] private Text counterText; // UGUI의 Text

private void Start()
{
// 타이머 카운터가 변화한 이벤트를 받고 UGUI Text를 업데이트
timeCounter.OnTimeChanged += time => // "=>" 는 람다식이라는 익명 함수 표기법
{
// 현재 타이머 값을 UI에 반영
counterText.text = time.ToString();
};
}
}

위의 코드는 타이머 카운트 다운 숫자를 이벤트로서 통지, 이벤트를 받는 측에서 UGUI Text를 갱신하는 샘플 코드 입니다.

UniRx

왜 갑자기 event의 이야기를 했느냐하면 "UniRx는 event의 완전한 상위 호환이며, event에 비해 보다 유연한 기술을 사용할 수 있다" 이기 때문입니다.

앞의 코드는 UniRx를 이용하면 다음과 같이 작성할 수 있습니다.

이벤트 게시자

using System;
using System.Collections;
using UniRx;
using UnityEngine;

/// <summary>
/// 100에서 카운트 다운하고 Debug.Log에 그 값을 표시하는 샘플
/// </summary>
public class TimeCounterRx : MonoBehaviour
{
// 이벤트를 발행하는 핵심 인스턴스
private Subject<int> timerSubject = new Subject<int>();

// 이벤트의 구독자만 공개
public IObservable<int> OnTimeChanged => timerSubject;

private void Start() => StartCoroutine(TimerCoroutine());

private IEnumerator TimerCoroutine()
{
// 100에서 카운트 다운
var time = 100;
while (time > 0)
{
time--;

// 이벤트를 발행
timerSubject.OnNext(time);

// 1초 기다리는
yield return new WaitForSeconds(1);
}
}
}

이벤트 구독자

using UnityEngine;
using UnityEngine.UI;
using UniRx;

public class TimerViewRx : MonoBehaviour
{
// 각 인스턴스는 인스펙터에서 설정
[SerializeField] private TimeCounterRx timeCounter;
[SerializeField] private Text counterText; // UGUI의 Text

private void Start() =>
// 타이머 카운터가 변화한 이벤트를 받고 UGUI Text를 업데이트
timeCounter.OnTimeChanged.Subscribe(time =>
{
// 현재 타이머 값을 ui에 반영
counterText.text = time.ToString();
});
}

위의 event로 구현하고 있던 코드를 UniRx로 변환한 코드 입니다.

event 대신 Subject라는 클래스가 이벤트 핸들러를 등록할 때 Subscribe 하는 방법이 등장하고 있습니다. 즉 UniRx는

Subject가 이벤트 구조의 핵심이 되고, Subject에 값을 전달(OnNext)하고 Subject에 구독 (Subscribe)해서 메시지를 전달 될 수 있는 시스템으로 되어 있다는 것을 알 수 있다고 생각합니다.

그럼 다음 섹션에서 "OnNext와 Subscribe"에 대해 자세히 설명하겠습니다.

2. "OnNext와 Subscribe"

OnNext와 Subscribe는 모두 "Subject에 구현된 메서드"이며, 각각 다음과 같은 동작을 하고 있습니다.

  • Subscribe: 메시지 수신시 실행을 함수에 등록한다.
  • OnNext: Subscribe에 등록 된 함수에 메시지를 전달하고 실행한다.

아래 코드를 참조하십시오.

OnNext와 Subscrbie

private void Start()
{
// Subject 작성
Subject<string> subject = new Subject<string>();

// 3회 Subscrbie
subject.Subscribe(msg => Debug.Log("Subscribe1 : " + msg));
subject.Subscribe(msg => Debug.Log("Subscribe2 : " + msg));
subject.Subscribe(msg => Debug.Log("Subscribe3 : " + msg));

// 이벤트 메시지 발행
subject.OnNext("안녕하세요");
subject.OnNext("안녕");
}

실행결과

Subscribe1 : 안녕하세요
Subscribe2 : 안녕하세요
Subscribe3 : 안녕하세요
Subscribe1 : 안녕
Subscribe2 : 안녕
Subscribe3 : 안녕

이와 같이, "Subscribe는 메시지 수신시의 처리를 등록 처리", "OnNext는 메시지를 발행하고 Subscribe에 등록 된 처리를 순서대로 호출 해 나가는 처리"를 하고 있음을 알 수 있습니다.

이번에는 구체적으로 Subscribe의 행동에 초점을 맞추고, UniRx의 기본적인 작동 원리와 개념을 설명했습니다.

다음 섹션에서는 좀 더 추상화된 이미지인 "IObserver 인터페이스""IObservable 인터페이스"에 대해 설명하겠습니다.

4. "IObserver와 IObservable"

이전에 Subject에 "OnNext"와 "Subscribe"의 2개의 메소드가 구현되어 있다고 설명했습니다.

이것은 사실 대락적인 설명이며, 정확한 설명은 아닙니다.

더 정확히 설명을 하면 "Subject는 IObserver 인터페이스와 IObservable인터페이스 2개를 구현하고 있다"라고 설명 할 수 있습니다.

이 IObserver 인터페이스와 IObservable 인터페이스는 도대체 무엇인가?에 대해 자세히 설명 드리겠습니다.

IObserver 인터페이스

IObserver 인터페이스(이후 IObserver)는 Rx에서 "이벤트 메시지를 발행 할 수 있다"라는 행동을 정의한 인터페이스 입니다. 정의는 다음과 같습니다.

using System;

namespace UniRx