5. LazyOptional의 고급 사용법 - Tictim/Capabilities-Within-TileEntity GitHub Wiki

LazyOptional은 개념적으로 복잡하지 않지만, 여러 상황에서 그다지 직관적이지 않은 부분을 가지고 있습니다. 이것들을 모르고 지나친다면 잘못된 코드를 짤 가능성이 높습니다.

첫 번째로 중요한 LazyOptional의 특성 중 하나는 재사용 가능하다는 점입니다. LazyOptional은 다른 TileEntity와 같은 객체의 내부에 캐시될 수 있으며, 캐시된 LazyOptional은 특수한 인풋 이전까지는 Capability에 대한 유효한 값을 제공해야 합니다. 다음 예제를 봅시다.

public class AnotherTileEntity extends TileEntity implements ITickableTileEntity{
    private LazyOptional<IMyAwesomeMagic> adjacentMagic = LazyOptional.empty();

    @Override
    public void tick() {
        if(world.isRemote) return;
        IMyAwesomeMagic magic = adjacentMagic.orElse(null);
        if(magic!=null) {
            // Do the thing
        }
    }

    public void refreshAdjacentMagic(){
        TileEntity te = world.getTileEntity(pos.offset(Direction.UP));
        if(te!=null) {
            adjacentMagic  = te.getCapability(ModCapabilities.MY_AWESOME_MAGIC);
        }else adjacentMagic = LazyOptional.empty();
    }
}

Capability를 사용하는 모습이 보이는군요... 하지만 이 코드는 뭔가 다릅니다. 이 TileEntity는 Capability를 제공하지 않습니다. 대신, 이 TileEntity는 자신의 바로 위 블럭에 있는 Capability를 사용합니다. 그리고 이 과정에서 전달받은 LazyOptional을 내부에 저장하는 모습을 볼 수 있습니다. 이는 매 틱마다, 또는 매 접근마다 TileEntity에 접근하여 Capability를 뜯어오는 번거로운 과정을 생략할 수 있도록 합니다.

하지만 이 구조가 완벽한 것은 아닙니다. 만약 받아온 LazyOptional이 공급원 TileEntity가 월드에서 사라지거나 하는 이유로 무효화되었다면, adjacentMagic 필드는 잘못된 값으로의 추가적인 접근을 막기 위해 LazyOptional.empty()로 초기화되어야 합니다. 어떻게 해야 할까요? 한정된 상황에서라면 단순히 블럭의 변화를 체크할 수도 있겠습니다만, 만약 공급원 TileEntity의 내부에서 Capability로 반환한 LazyOptional을 초기화하고 새 값을 반환해야만 한다면? ICapabilityProvider는 TileEntity뿐만이 아니며, Capability의 사용처 또한 TileEntity에 한정된 것이 아닙니다. LazyOptional의 수명을 관리하고 이를 통보해야 하는 상황은 충분히 존재합니다.

다행히도, LazyOptional은 자체적으로 이러한 상황을 처리할 수 있도록 invalidate 메소드를 제공합니다.

myLazyOptional.invalidate();

invalidate가 호출된 LazyOptional은 그 기능을 영구적으로 잃습니다. LazyOptional의 값은 더 이상 접근될 수 없으며, isPresent 체크는 false를 반환합니다.

또한 invalidate가 호출될 때 LazyOptional은 이벤트를 생성합니다. 아뇨, 포지 이벤트 말고요! 여기에서 이벤트는 LazyOptional 내부에서 작동하는 콜백을 이야기합니다.

myLazyOptional.addListener(lazyOptional -> {
    // 이 코드는 `myLazyOptional.invalidate()` 메소드 내부에서 호출됩니다.
});

LazyOptional의 제공

그렇다면, 이 이벤트를 코드에 적용시켜 봅시다! 먼저 Capability를 제공하는 클래스부터 살펴봅시다.

public class MyTileEntity extends TileEntity {
    private final IMyAwesomeMagic myAwesomeMagic = /* ... */;

    private final LazyOptional<IMyAwesomeMagic> myAwesomeMagicCap = LazyOptional.of(() -> myAwesomeMagic);

    @Override
    public <T> LazyOptional<T> getCapability(Capability<T> cap, @Nullable Direction side){
        if(cap==ModCapabilities.MY_AWESOME_MAGIC){
            return myAwesomeMagicCap.cast();
        }
        return super.getCapability(cap, side);
    }

    @Override
    public void remove(){
        super.remove();
        myAwesomeMagicCap.invalidate();
    }

기존과 똑같은 코드 베이스에 추가적인 메소드 한 개를 오버라이드하여 invalidate를 처리하는 것을 보실 수 있습니다. remove는 타일엔티티가 블럭의 전환이나 언로딩 등으로 인해 월드에서 사라질 때 호출되며, TileEntity의 내부에서 만들어진 모든 Capability들이 여기에서 invalidate되어야 합니다.

만약 여러분이 LazyOptional을 통해 제공하던 인스턴스를 바꾸어야 한다면(매우 권장되지 않습니다만!), 이전 LazyOptional을 버리기 이전에 invalidate를 반드시 호출해야 합니다.

public class MyTileEntity extends TileEntity {
    private IMyAwesomeMagic myAwesomeMagic = /* ... */;

    // myAwesomeMagic 인스턴스가 교체될 때 호출해야 합니다!
    private void setMyAwesomeMagic(IMyAwesomeMagic myAwesomeMagic){
        this.myAwesomeMagic = myAwesomeMagic;
        if(myAwesomeMagicCap!=null){
            myAwesomeMagicCap.invalidate();
            myAwesomeMagicCap = null;
        }
    }

    // LazyOptional 인스턴스는 Capability의 요청에 맞추어 만들어도 충분합니다.
    @Nullable
    private LazyOptional<IMyAwesomeMagic> myAwesomeMagicCap;

    @Override
    public <T> LazyOptional<T> getCapability(Capability<T> cap, @Nullable Direction side){
        if(cap==ModCapabilities.MY_AWESOME_MAGIC){
            if(myAwesomeMagicCap==null) myAwesomeMagicCap = LazyOptional.of(() -> myAwesomeMagic);
            return myAwesomeMagicCap.cast();
        }
        return super.getCapability(cap, side);
    }

    @Override
    public void remove(){
        super.remove();
        if(myAwesomeMagicCap==null) myAwesomeMagicCap.invalidate();
    }

이제 우리는 왜 이전에 보았던 코드가 나쁜 예제인지 알 수 있습니다. 이 코드 기억나시나요?

@Override
public <T> LazyOptional<T> getCapability(Capability<T> cap, @Nullable Direction side){
    if(cap==ModCapabilities.MY_AWESOME_MAGIC){
        return LazyOptional.of(() -> myAwesomeMagic); // 나쁜 코드!
    }
    return super.getCapability(cap, side);
}

이 코드가 좋은 코드가 아닌 이유는, 바로 생성한 LazyOptional들을 무효화할 방법이 존재하지 않기 때문입니다.

LazyOptional의 사용

그리고 이렇게 제공되는 LazyOptional은 아래와 같이 사용될 수 있습니다. 그다지 많은 설명이 필요하지는 않으니, 코드를 알아서 분석해 보시기 바랍니다.

public class AnotherTileEntity extends TileEntity implements ITickableTileEntity{
    private LazyOptional<IMyAwesomeMagic> adjacentMagic = LazyOptional.empty();
    // 타일엔티티의 첫 tick()에 TileEntity의 Capability를 가져오기 위해서 초기값을 true로 설정합니다.
    private boolean refreshAdjacentMagic = true;

    // refreshAdjacentMagic를 외부에서 접근하기 위한 메소드.
    public void markRefreshAdjacentMagic(){
        this.refreshAdjacentMagic = true;
    }

    @Override
    public void tick() {
        if(world.isRemote) return;
        if(this.refreshAdjacentMagic){
            this.refreshAdjacentMagic = false;
            TileEntity te = world.getTileEntity(pos.offset(Direction.UP));
            if(te!=null) {
                adjacentMagic  = te.getCapability(ModCapabilities.MY_AWESOME_MAGIC);
                if(adjacentMagic.isPresent()) adjacentMagic.addListener(o -> markRefreshAdjacentMagic());
            }else adjacentMagic = LazyOptional.empty();
        }
        IMyAwesomeMagic magic = adjacentMagic.orElse(null);
        
        if(magic!=null) {
            // Do the thing
        }
    }
}

물론 이 코드로는 TileEntity 주변 블럭의 변화를 감지할 수 없으니, 이는 블럭에서 따로 처리를 해 주어야 합니다.

// AnotherBlock.java

@Override
public void neighborChanged(BlockState state, World world, BlockPos pos, Block block, BlockPos fromPos, boolean isMoving){
    if(pos.offset(Direction.UP).equals(fromPos)){
        TileEntity te = world.getTileEntity(fromPos);
        if(te instanceof AnotherTileEntity) ((AnotherTileEntity)te).markRefreshAdjacentMagic();
    }
}

// ...

마지막으로, 위처럼 LazyOptional을 캐시하는 방법을 모든 Capability의 사용처에 적용시킬 수는 없습니다. 비 주기적인 Capability의 접근, 혹은 Capability의 캐시가 불가능한 경우에는 예전에 배운 것처럼 LazyOptional에서 값을 그대로 꺼내오는 것으로 충분합니다. Capability의 사용에 어떠한 메소드를 사용할 것인가는 여러분의 판단에 맡기겠습니다.