글쓴이 보관물: yoonhada

[번역] 에셋번들 사용 패턴

이 글은 유니티 튜토리얼을 번역한 글 입니다. 원문은 여기에서 확인하실 수 있습니다.

이 글은 총 5개의 챕터로 구성되며 아래와 같습니다.

  1. [번역] 에셋번들과 리소스에 대한 가이드
  2. [번역] 에셋과 오브젝트, 그리고 직렬화
  3. [번역] RESOURCES 폴더
  4. [번역] 에셋번들 기초
  5. [번역] 에셋번들 사용 패턴

이 글은 [번역] 에셋번들과 리소스에 대한 가이드 시리즈의 5번째 챕터입니다.

이 시리즈의 이전 챕터에서는 에셋번들 기초에 대해 다뤘는데, 특히 다양한 로딩 API의 저수준 동작들에 대해 이야기했습니다. 이번 챕터에는 에셋번들을 실제로 사용하는 방법에 있어서 발생하는 문제와 해결법에 대해 이야기할 것 입니다.

4.1 로드된 에셋의 관리

메모리에 민감한 환경에서 로드된 오브젝트의 크기와 갯수를 조심스레 다루는 것은 특히 중대한 문제입니다. 유니티는 현재 씬에서 오브젝트가 제거되더라도 자동으로 해당 오브젝트를 언로드하지 않습니다. 에셋의 정리는 특정 시간에 발생하게 되고, 수동으로도 발생시킬 수 있습니다.

에셋번들 자체는 조심스럽게 다뤄져야 합니다. 로컬 저장 공간에 있는(유니티 캐쉬에 있거나AssetBundle.LoadFromFile에 의해 로드된 것) 파일의 에셋번들은 최소한의 메모리 오버헤드가 있는데, 거의 10~40 KB 를 넘지 않습니다. 이러한 숫자도 에셋번들이 많은 경우에는 문제가 될 수 있습니다.

대부분의 프로젝트에서 유저들은 컨텐츠를 다시 경험할 수 있기 때문에(특정 레벨을 다시 플레이하는 등), 에셋번들을 언제 로드하고 언로드하는지를 아는 것이 중요합니다. 만약 에셋번들이 부적절하게 언로드된다면, 메모리에서 오브젝트가 중복되는 문제를 야기시킬 수 있습니다. 부적절한 에셋번들의 언로드는 특정 환경에서 바람직하지 못한 동작을 할 수도 있는데, 예를 들면 텍스쳐가 없는 상태(missing)가 될 수도 있다는 말입니다. 왜 이러한 일이 일어나는지에 대해 알고 싶으면, 에셋과 오브젝트, 그리고 직렬화 챕터의 오브젝트 내부의 참조들 섹션을 참고해 보십시오.

에셋과 에셋번들을 관리할 때 가장 중요한 것은 AssetBundle.Unload 를 true나 false 인자를 사용해서 호출할 때의 동작의 차이를 이해해야 한다는 것 입니다.

이 API는 호출된 에셋번들의 헤더 정보를 언로드합니다. 여기에 사용되는 매개변수는 이 에셋번들의 모든 생성된 오브젝트를 같이 언로드할 것인지를 가리킵니다. true인 경우에 그 에셋번들에서 만들어진 모든 객체들 또한 즉시 언로드 됩니다 – 그 객체들이 현재 씬에서 사용되고 있더라도 말이죠.

예를 들어, 매터리얼 M이 에셋번들 AB에서 로드 되었고, 매터리얼 M이 현재 씬에서 사용되고 있다고 가정해보겠습니다.

그림 1


만약 AB.Unload(true)가 호출되면, M이 씬에서 제거되고, 파괴되며 언로드되게 됩니다. 하지만, 만약 AB.Unload(false)가 호출되면, AB의 헤더만 언로드되고 M은 여전히 씬에 남아서 동작할 것 입니다. AssetBundle.Unload(false)를 호출하는 것은 M과 AB 사이의 링크를 깨트립니다. 만약 AB가 그 후에 다시 로드되면, AB에 포함되어 있는 오브젝트의 새로운 복제본이 메모리에 올라가게 됩니다.

그림 2


만약 AB가 그 후에 다시 로드되게 된다면, 에셋번들의 헤더 정보가 새로 복제되어 다시 로드되게 됩니다. 그런데, M은 새로운 AB의 복제본이 아닌 이전 AB에서 생성된 것 입니다. 유니티는 AB의 새로운 복제본과 M 사이의 링크를 형성해주지 않습니다.

그림 3


만약 M을 다시 로드하기 위해 AB.LoadAsset()을 호출하면, 유니티는 이전에 생성한 M을 AB의 인스턴스라고 해석하지 않습니다. 따라서, 유니티는 M에 대한 새로운 복제본을 생성하게 되고, 씬에는 똑같은 2개의 M에 대한 복제본이 존재하게 되는 것 입니다.

그림 4


대부분의 프로젝트에서, 이러한 동작은 바람직하지 못 합니다. 대부분의 프로젝트에서는 AssetBundle.Unload (true)를 사용해야하고, 오브젝트가 중복되지 않을 수 있는 방법을 채택해야 합니다. 흔한 두 가지 방법은 다음과 같습니다 :

  1. 레벨 사이나 로딩 화면처럼 애플리케이션의 생명 주기 동안 일시적 에셋번들이 언로드되는 지점을 체크하는 것 입니다. 이는 간단하고 가장 흔한 옵션입니다.
  2. 각 오브젝트의 참조 개수(reference-count)를 유지하고, 에셋번들은 그 오브젝트들이 하나도 사용되지 않을 때에만 언로드시킵니다. 이는 애플리케이션이 메모리의 중복없이 각 오브젝트를 로드하고 언로드할 수 있게 해줍니다.

만약 애플리케이션이 AssetBundle.Unload (false)를 사용해야만 한다면, 각 오브젝트는 다음의 두 가지 방법으로만 언로드될 수 있습니다 :

  1. 씬과 코드에서 원치않는 오브젝트에 대한 모든 참조를 제거하십시오. 이 작업이 끝나고 나면 Resources.UnloadUnusedAssets을 호출하십시오.
  2. 씬을 additive 방식이 아닌 방식으로 로드하십시오. 이는 현재 씬의 모든 오브젝트를 파괴시키고 Resources.UnloadUnusedAssets를 자동으로 호출합니다.

만약 프로젝트에 오브젝트를 로드하고 언로드하기 위해 유저를 기다리게 할 수 있는 잘 정의된 지점이 있다면(예를 들어 게임 모드나 레벨 사이), 이러한 지점들은 가능한 많은 오브젝트를 언로드하고 새로운 오브젝트를 로드하도록 사용되어야 합니다.

이렇게 하기 위한 가장 간단한 방법은 프로젝트의 불연속적인 덩어리들을 씬에 넣고, 이 씬과 연결된 모든 참조들을 에셋번들로 빌드하는 것 입니다. 그리고나서 애플리케이션은 “로딩” 씬으로 들어가서 이전 씬에 있던 에셋번들을 모두 언로드하고, 새로운 씬의 에셋번들을 로드합니다.

이 방식이 가장 간단한 방법이지만, 일부 프로젝트는 좀 더 복잡한 에셋번들 관리가 필요합니다. 통괄적으로(universal) 쓰일 수 있는 에셋번들 디자인 패턴은 없습니다. 각 프로젝트의 데이터는 다릅니다. 에셋번들에 오브젝트를 어떻게 그룹화해서 넣을지를 결정할 때, 동시에 로드되거나 업데이트 되어야하는 오브젝트들을 에셋번들에 넣는 것이 일반적으로 최선입니다.

예를 들어, RPG(role-playing game)를 생각해보자. 각 맵과 컷씬은 에셋번들로 그룹화될 수 있는데, 일부 오브젝트는 대부분의 씬에서 필요할 것 입니다. 에셋번들은 초상화, 인게임 UI, 다른 캐릭터 모델과 텍스쳐를 제공하기 위해 만들어 질 수 있습니다. 이런 오브젝트과 에셋들은 에셋번들의 두번 째 셋으로 그룹화될 수 있고, 이는 시작 시에 로드되고 앱의 생명주기 동안 로드된 채 남아 있습니다.

또 다른 문제는 에셋번들이 언로드 된 후에 다시 그 에셋번들로부터 오브젝트를 다시 로드하려고 하는 경우에 발생할 수 있습니다. 이러한 경우에, 다시 로드하는 것은 실패하게 되고 그 오브젝트는 유니티 에디터의 hierarchy에서 (Missing) 오브젝트 상태로 보이게 됩니다.

이는 주로 유니티가 그래픽 컨텍스트에 대한 제어를 잃었다가 다시 얻었을 때 발생하는데, 모바일 앱이 중지 되거나 유저가 PC를 잠금 상태로 변환했을 때가 이에 해당합니다. 이러한 경우에, 유니티는 텍스쳐와 셰이더를 GPU에 다시 업로드해야 합니다. 만약 이런 에셋을 위한 소스 에셋번들이 사용 불가능한 상태라면, 애플리케이션은 씬의 오브젝트를 “missing shader” 상태인 magenta 색상으로 그리게 됩니다.

4.2 배포

프로젝트의 에셋번들을 고객에게 배포하는 기본적인 2가지 방법이 있습니다 : 프로젝트와 동시에 설치하는 것 또는 설치 후에 다운로드하는 방법입니다. 에셋번들을 설치에 포함 시킬 지 아니면 설치 후에 전달할 지에 대한 결정은 프로젝트가 구동할 플랫폼의 수용성(capabilities)과 제한(restrictions)에 따르게 됩니다. 모바일 프로젝트는 주로 설치 후에 다운로드 하는 방식을 선택하는데, 이는 초기 설치 크기를 줄이기 위함과 무선 다운로드 크기 제한1을 맞추기 위함입니다. 콘솔과 PC 프로젝트는 일반적으로 에셋번들을 초기 설치에 포함시킵니다.

적절한 아키텍쳐는 초기에 에셋번들을 어떤 형태로 전달했는지에 상관없이 설치 후에 새로운 컨텐츠나 수정된 컨텐츠를 패치할 수 있도록 해줍니다. 여기에 대한 더 자세한 내용을 알고 싶으면, 이 글의 에셋번들로 패치하기 섹션을 살펴보세요.

4.2.1. 프로젝트와 같이 전달

에셋번들을 프로젝트에 같이 담아서 전달하는 것이 가장 간단한 배포 방법입니다. 왜냐하면 어떤 추가적인 다운로드 관리 코드가 필요없기 때문이죠. 설치 시에 왜 에셋번들을 포함시켜야 하는 지에 대한 2가지 주요한 이유는 다음과 같습니다 :

  • 프로젝트의 빌드 시간을 줄이고 더 간단한 반복 개발을 가능하게 하기 위함입니다. 만약 에셋번들이 애플리케이션으로 부터 독립적으로 업데이트 될 필요가 없다면, 그 에셋번들은 Streaming Assets으로 저장함으로써 애플리케이션에 포함될 수 있습니다. 아래의 Streaming Assets 섹션을 보세요.
  • 업데이트 가능한 컨텐츠의 초기 버전을 같이 전달하기 위함입니다. 이는 최초 설치 후 유저의 시간을 절약하기 위해서, 또는 추후의 패치를 위한 기본을 제공하기 위해서 주로 행해집니다. Streaming Assets은 이 경우에는 이상적인 방법이 아닙니다. 하지만, 커스텀 다운로드 코드와 캐시 시스템을 작성하는 것이 선택 사항이 아니라면, 업데이트 가능한 컨텐츠의 최초 버전은 Streaming Assets로부터 유니티 캐쉬에 로드될 수 있습니다.

4.2.1.1. Streaming Assets

설치 시에 유니티 애플리케이션에 어떤 형태의 컨텐츠를 포함시키는 가장 쉬운 방법은 해당 컨텐츠를 빌드 전에 /Assets/StreamingAssets/ 폴더에 넣는 것 입니다. 빌드 시에 StreamingAssets 폴더에 포함되어 있는 모든 것들은 최종 애플리케이션에 복사되어 질 것 입니다. 이 폴더는 최종 애플리케이션에 에셋번들 뿐만 아니라 어떤 종류의 컨텐츠도 저장해서 쓸 수 있습니다.

로컬 저장 공간에 있는 StreamingAssets 폴더의 전체 경로는 런타임에 Application.streamingAssetsPath 속성을 이용해서 접근할 수 있습니다. 그렇게해서 대부분의 플랫폼에서 AssetBundle.LoadFromFile을 통해 에셋번들을 로드할 수 있습니다.

안드로이드 개발자 : 안드로이드에서는, 에셋번들이 압축되어 있더라도, Application.streamingAssetsPath는 압축된 .jar 파일을 가리키게 됩니다. 이런 경우에, 각 에셋번들을 로드하기 위해 WWW.LoadFromCacheOrDownload가 사용되어야 합니다. .jar 파일의 압축을 푼 뒤에 로컬 저장소에 있는 읽기 가능한 위치로 에셋번들을 추출하는 커스텀 코드를 작성하는 것 또한 가능합니다.

메모 : Streaming Assets은 일부 플랫폼에서 쓰기 가능한 위치가 아닙니다. 만약 프로젝트의 에셋번들이 설치 후에 업데이트 되어야 한다면, WWW.LoadFromCacheOrDownload를 쓰거나 커스텀 다운로더를 작성해야 합니다. 더 자세한 내용을 알고 싶다면 커스텀 다운로더 – 저장공간 섹션을 읽어 주세요.

4.2.2. 설치 후 다운로드(Downloaded post-install)

모바일 장치에 에셋번들을 전달할 때 많이 사용되는 방법은 앱 설치 후 번들을 다운로드하는 방법입니다. 이는 또한 설치 후에 전체 애플리케이션을 다시 다운로드할 필요 없이, 새로운 컨텐츠나 정제된 컨텐츠를 업데이트할 수 있도록 해줍니다. 모바일 플랫폼에서는, 애플리케이션 이진파일은 비싸고 긴 재인증 프로세스를 거쳐야만 합니다. 따라서, 설치 후 다운로드를 위한 좋은 시스템을 개발하는 것은 매우 중요합니다.

에셋번들을 전달하는 가장 간단한 방법은 웹서버에 그 번들을 두고 WWW.LoadFromCacheOrDownload나 UnityWebRequest를 통해서 전달하는 것 입니다. 유니티는 다운로드된 에셋번들을 로컬 저장 공간에 자동으로 저장합니다. 만약 다운로드한 에셋번들이 LZMA로 압축되어 있다면, 추후에 빠르게 로딩하기 위해 압축을 해제한 상태로 저장되게 됩니다. 만약 다운로드한 번들이 LZ4로 압축되어 있다면, 압축된 상태 그대로 저장되게 됩니다.

캐쉬가 가득차게 되면, 유니티는 가장 오래 전에 사용된 에셋번들을 캐쉬에서 지우게 됩니다. 더 자세한 내용은 내장 캐쉬 섹션에서 보세요.

WWW.LoadFromCacheOrDownload에는 결함이 있다는 것을 알아두세요. 에셋번들의 로드 섹션에 설명했듯이, WWW 오브젝트는 에셋번들을 다운로드하는 동안 동일한 크기의 메모리를 소비하게 됩니다. 이는 수용할 수 없는 메모리의 급증을 유발할 수 있습니다. 이를 피하기 위한 3가지 방법은 다음과 같습니다 :

  • 에셋번들의 크기를 작게 유지하세요. 에셋번들의 최대 크기는 번들이 다운로드될 때 프로젝트의 메모리 예산에 맞게 결정될 것 입니다. “downloading” 화면이 있는 애플리케이션은 백그라운드에서 에셋번들을 스트리밍하는 애플리케이션보다 보통은 더 많은 메모리를 할당합니다.
  • 유니티 5.3 이상에서는, UnityWebRequest API의 DownloadHandlerAssetBundle을 쓰도록 변경하세요. 이는 다운로드 중에 메모리의 급증을 야기하지 않습니다.
  • 커스텀 다운로더를 작성하세요. 더 자세한 정보를 원한다면, 커스텀 다운로더 섹션을 보세요.

가능하다면 UnityWebRequest를 써서 시작하는 것을 일반적으로 추천드리지만, 유니티 5.2 이하에서는 WWW.LoadFromCacheOrDownload를 추천드립니다. 유니티 기본 시스템 API의 메모리 소비, 캐쉬 동작, 성능이 특정 프로젝트에서 수용할 수 없을 정도이거나, 어떤 요구사항을 만족하기 위해 플랫폼 종속적인 코드를 실행시켜야만 하는 프로젝트의 경우에만 커스텀 다운로드 시스템에 투자를 하세요.

UnityWebRequest나 WWW.LoadFromCacheOrDownload의 사용을 막을 수도 있는 상황에 대한 예 입니다 :

  • 에셋번들 캐쉬에 대한 아주 세밀한 조절이 필요한 경우
  • 프로젝트에서 커스텀 압축 전략을 구현해야하는 경우
  • 비활성화 상태에서 데이터를 스트리밍하는 등의 특별한 요구사항을 만족하기 위해 플랫폼 제한적인 코드를 사용해야하는 경우
    • 예 : 백그라운드 상태일 때 데이터를 다운로드하기 위해 iOS의 Background Tasks API를 사용하는 경우 PC처럼 유니티의 적절한 SSL 지원이 없는 플랫폼에 SSL로 에셋번들을 전송해야하는 경우

4.2.3. 내장 캐쉬

유니티는 내장 에셋번들 캐쉬 시스템이 있는데, 이 시스템은 WWW.LoadFromCacheOrDownload나 UnityWebRequest API를 통해 다운로드 된 에셋번들을 캐쉬하는데에 사용될 수 있습니다.

두 API는 모두 매개변수로 에셋번들의 버전 번호를 받는 오버로드 메서드를 가지고 있습니다. 이 번호는 에셋번들의 내부에 저장되어 있지 않고, 에셋번들 시스템에 의해 생성되지도 않습니다.

캐쉬 시스템은 WWW.LoadFromCacheOrDownload나 UnityWebRequest로 전달된 가장 최근의 버전 번호를 유지하고 있습니다. 둘 중 어떤 API가 버전 번호를 넘겨받아서 호출되면, 캐쉬 시스템은 캐쉬된 에셋번들이 있는지 체크합니다. 만약 있다면, 에셋번들이 처음 캐쉬된 경우에 전달된 버전 번호와 현재 호출에 사용된 버전 번호를 비교해 봅니다. 이 번호가 같다면, 시스템은 캐쉬된 에셋번들을 로드하게 됩니다. 만약 번호가 다르다면, 캐쉬된 에셋번들이 없음을 의미하고, 유니티는 새로운 복사본을 다운로드하게 됩니다. 이 새로운 복사본은 새로운 버전 번호와 관계가 맺어집니다.

캐쉬 시스템에 있는 에셋번들은 파일명으로만 식별되고, 다운로드되는 전체 url과는 상관없습니다. 이는 같은 파일명의 에셋번들이 여러 다른 곳에 저장되어도 괜찮다는 의미입니다. 예를 들어, 하나의 에셋번들이 한 Content Delivery Network(CDN)의 여러 서버에 위치할 수 있습니다. 파일명이 같은 한, 캐쉬 시스템은 이들을 같은 에셋번들로 취급합니다.

에셋번들에 버전 번호를 할당하는 적절한 전략을 결정하고, 이 버전 번호를 WWW.LoadFromCacheOrDownload에 전달하는 것은 개별 애플리케이션에 달려있는 부분입니다. 대부분의 애플리케이션은 유니티 5의 AssetBundleManifest API를 사용할 수 있습니다. 이 API는 에셋번들의 컨텐츠에 대한 MD5 해쉬를 계산해서 각 에셋번들의 버전 번호를 생성할 수 있습니다. 에셋번들이 바뀔 때마다, 해쉬 값 또한 변하게 되고, 이는 에셋번들을 다운로드해야 함을 가리킵니다.

메모 : 유니티 내장 캐쉬 구현의 변덕(quirk)2 때문에, 오래된 에셋번들은 캐쉬가 가득 찰 때까지는 지워지지 않습니다. 유니티는 추후 릴리즈에서 이런 변덕에 대해 언급할 예정입니다.

더 자세한 내용을 알고 싶으면 에셋번들로 패치하기 섹션을 보십시오.

유니티의 내장 캐쉬는 캐쉬 객체에 있는 API를 호출함으로써 조절할 수 있습니다. 유니티 캐쉬의 동작은 Caching.expirationDelay와 Caching.maximumAvailableDiskSpace를 변경함으로써 조절할 수 있습니다.

Caching.expirationDelay는 에셋번들이 자동으로 삭제되기 위해 필요한 최소한의 시간(초 단위)입니다. 만약 에셋번들이 이 시간 동안 접근되지 않았다면, 자동으로 삭제될 것 입니다.

Caching.maximumAvailableDiskSpace 은 expirationDelay보다 덜 최근에 사용된 에셋번들을 삭제하기 시작하기 전에 캐쉬가 사용할 수 있는 로컬 저장 공간의 크기를 결정합니다. 여기에는 바이트 단위를 사용합니다. 한계 용량에 도달하게 되면, 유니티는 캐쉬 중에서 최근에 가장 적게 열었던 에셋번들(또는 Caching.MarkAsUsed를 통해 사용됐다고 표시된 에셋번들)을 삭제하게 됩니다. 유니티는 새로운 다운로드를 완료하기에 충분한 용량이 되기 전까지 캐쉬된 에셋번들을 삭제할 것 입니다.

메모 : 유니티 5.3에서, 내장 유니티 캐쉬를 조절하는 것은 매우 힘든 일 입니다. 캐쉬에서 특정 에셋번들을 삭제하는 것은 불가능합니다. 삭제는 시간적인 만기에 의해서, 디스크 사용 공간의 초과에 의해서, 또는 Caching.CleanCache(Caching.CleanCache 는 현재 캐쉬에 있는 모든 에셋번들을 삭제할 것 입니다.)에 의해서 발생합니다. 이는 개발 중이거나 라이브 동작 중일 때 문제가 될 수 있는데, 유니티가 애플리케이션에서 더 이상 사용되지 않는 에셋번들을 자동으로 지워주지 않기 때문입니다.

4.2.3.1. Cache Priming

에셋번들은 파일명으로 식별되기 때문에, 애플리케이션에 같이 포함되는 에셋번들의 캐쉬를 가장 주요한 것으로 설정하는 것이 가능합니다. 이를 위해서, /Assets/StreamingAssets/에 있는 각 에셋번들의 최초 버전을 저장하십시오. 이 과정은 프로젝트와 같이 전달 섹션에 있는 상세 내용과 같습니다.

애플리케이션이 최초에 실행될 때 Application.streamingAssetsPath로부터 에셋번들을 로딩함으로써 캐쉬를 로드할 수 있습니다. 그 때 부터, 애플리케이션은 WWW.LoadFromCacheOrDownload나 UnityWebRequest를 일반적으로 호출할 수 있습니다.

4.2.4. 커스텀 다운로더

커스텀 다운로더를 작성하는 것은 어떻게 에셋번들이 다운로드되고, 압축 해제되고 저장되는 지에 대한 모든 제어를 애플리케이션에 줄 수 있습니다. 커스텀 다운로더를 작성하는 것은 야심찬 애플리케이션을 만드는 큰 팀에 한해서만 추천드립니다. 커스텀 다운로더를 작성할 때 생각해야 할 4가지 주요 문제에 대해 이야기 해보겠습니다 :

  • 에셋번들을 어떻게 다운로드할 것인가?
  • 에셋번들을 어디에 저장할 것인가?
  • 에셋번들을 어떻게 압축할 것인가?
  • 에셋번들을 어떻게 패치할 것인가?

에셋번들의 패치에 대한 정보를 원하시면, 에셋번들로 패치하기 섹션을 살펴보십시오.

4.2.4.1 다운로드

대부분의 애플리케이션에서, HTTP가 에셋번들을 다운로드하기에 가장 간단한 방법입니다. 하지만, HTTP 기반의 다운로더를 구현하는 것은 간단한 일이 아닙니다. 커스텀 다운로더는 메모리 초과 할당, 쓰레드 초과 사용, 쓰레드 초과 깨움(excessive thread wakeups)을 피해야만 합니다. 유니티의 WWW 클래스는 여기에서 철저히 설명하는 이유에 의해 부적절 합니다. WWW의 높은 메모리 비용 때문에, 유니티의 애플리케이션에서 WWW.LoadFromCacheOrDownload를 사용하지 않는다면 WWW 클래스는 피해야 합니다.

커스텀 다운로더를 작성할 때 3개의 옵션이 있습니다 :

4.2.4.1.1. C# 클래스

애플리케이션이 HTTPS/SSL 지원을 필요로 하지 않는다면, C#의 WebClient 클래스는 에셋번들을 다운로드하는데게 가장 간단한 메커니즘을 제공합니다. 초과 메모리 할당 없이, 비동기적으로 어떤 파일을 다운로드해서 로컬 저장 공간에 바로 넣는 것이 가능합니다.

WebClient를 이용해서 에셋번들을 다운로드하려면, 클래스의 인스턴스를 할당하고, 에셋번들을 다운로드하기 위한 URL과 목적지 경로를 전달해야 합니다. 요청의 매개변수를 통한 더 많은 제어가 필요한 경우에는 C#의 HttpWebRequest를 사용해서 다운로더를 작성하는 것이 가능합니다 :

  1. HttpWebResponse.GetResponseStream으로부터 바이트 스트림을 얻습니다.
  2. 스택에 고정 크기 바이트 버퍼를 할당합니다.
  3. 응답 스트림에서 읽어서 버퍼에 넣습니다.
  4. C#의 File.IO API나 어떤 다른 스트리밍 IO 스시템을 이용해서 버퍼를 디스크에 기록합니다.

플랫폼 메모 : iOS, 안드로이드, 윈도우즈 폰의 경우에만 유니티의 C# 런타임이 C# HTTP 클래스에 대한 HTTPS/SSL 지원을 합니다. PC에서는 C# 클래스를 통해서 HTTPS 서버에 접근하려고 하면 인증서 검증 에러(certificate validation errors)가 발생합니다.

4.2.4.1.2. 에셋 스토어 패키지

일부 에셋 스토어 패키지는 HTTP, HTTPS와 다른 프로토콜을 통해서 파일을 다운로드하도록 네이티브 코드 구현을 제공하고 있습니다. 유니티를 위한 커스텀 네이티브 코드 플러그인을 제작하기 전에 사용 가능한 에셋 스토어 패키지를 평가해보는 것을 추천드립니다.

4.2.4.1.3. 커스텀 네이티브 플러그인

커스텀 네이티브 플러그인을 작성하는 것은 유니티에서 데이터를 다운로드하기 위한 가장 시간이 많이 들고 가장 유연한 방법입니다. 높은 프로그래밍 시간과 높은 기술 위험도가 있기 때문에, 이 방법은 애플리케이션의 요구 사항을 만족할 다른 방법이 없는 경우에만 추천됩니다. 예를 들어, 유니티의 C# SSL 지원이 없는 윈도우즈, OSX, 리눅스 같은 플랫폼에서 애플리케이션이 SSL 통신을 해야만 하는 경우에, 커스텀 네이티브 플러그인이 필요할 수도 있습니다.

커스텀 네이티브 플러그인은 일반적으로 타겟 플랫폼의 네이티브 다운로드 API를 감싸게 됩니다. 예로써 iOS의 NSURLConnection과 안드로이드의 java.net.HttpURLConnection이 있습니다. 이런 API를 사용하기 위한 더 상세한 내용은 각 플랫폼의 네이티브 문서의 도움을 받으십시오.

4.2.4.2. 저장 공간

모든 플랫폼에서 Application.persistentDataPath는 애플리케이션이 실행되는 동안 유지되어야 하는 데이터를 저장하는데에 사용되어야 하는 쓰기 가능한 위치를 가리킵니다. 커스텀 다운로더를 작성할 때, 다운로드된 데이터를 저장하기 위해 Application.persistentDataPath의 하위 디렉토리를 사용할 것을 강력히 추천드립니다.

Application.streamingAssetPath는 쓰기 불가능하고 에셋번들 캐쉬로써는 나쁜 선택입니다. streamingAssetsPath의 위치에 대한 예는 다음과 같습니다 :

  • OSX : .app 패키지 안 ; 쓰기 불가능
  • Windows : 설치 디렉토리 안(예를 들어 Program Files) ; 일반적으로 쓰기 불가능
  • iOS : .ipa 패키지 안 ; 쓰기 불가능
  • 안드로이드 : 압축된 .jar 파일 ; 쓰기 불가능

4.3. 에셋 할당 전략

프로젝트의 에셋을 에셋번들로 어떻게 나눌 것인가에 대한 결정을 하는 것은 간단한 일이 아닙니다. 여기에서 지나치게 단순한 전략을 채용하는 유혹에 빠지기 쉬운데요, 예를 들면 모든 오브젝트를 하나의 에셋번들에 담는 것 처럼 말이죠. 그런데 이러한 해결책은 심각한 결함이 존재합니다 :

  • 너무 적은 에셋번들을 가지는 것은…
    • 런타임 메모리 사용량을 늘립니다.
    • 로딩 시간을 늘립니다.
    • 더 많은 다운로드를 필요로 합니다.
  • 너무 많은 에셋번들을 가지는 것은…
    • 빌드 시간을 늘립니다.
    • 개발이 복잡해 질 수 있습니다.
    • 전체 다운로드 시간이 늘어납니다.

중요 결정은 어떻게 에셋번들에 오브젝트를 그룹핑 하는가 입니다. 주요한 전략은 다음과 같습니다 :

  • 논리적인 실체(Logical entities)
  • 오브젝트 타입(Object Types)
  • 동시 컨텐츠(Concurrent content)

하나의 프로젝트에서 다른 카테고리의 컨텐츠를 위해 이러한 전략들을 섞어 쓸 수 있다는 것을 알아 두십시오. 예를 들어, 다른 플랫폼에 사용될 UI 요소들을 에셋번들에 같이 그룹핑하고, 인터렉티브 컨텐츠는 레벨이나 씬에 따라 그룹핑할 수 있습니다. 어떤 전략을 채용했는지와 상관없이, 따를 수 있는 가이드 라인이 여기에 있습니다 :

  • 거의 변화가 없는 오브젝트들과 자주 업데이트 되는 오브젝트들을 다른 에셋번들로 나누세요.
  • 동시에 로드될 것 같은 오브젝트들을 같이 그룹핑하세요.

예 : 모델과 여기에 사용되는 애니메이션과 텍스쳐

  • 만약 어떤 오브젝트가 여러 개의 다른 에셋번들에 있는 오브젝트들과 의존 관계에 있다면, 그 에셋을 독립된 에셋번들로 옮기세요.
    • 이상적으로, 자식 오브젝트를 부모 오브젝트와 같이 그룹핑하세요.
  • 만약 HD와 SD 버전의 텍스쳐처럼, 두 오브젝트가 동시에 로드될 것 같지 않다면, 이들을 분리된 에셋번들로 나누세요.
  • 만약 오브젝트가 같은 오브젝트인데 다른 임포터 셋팅을 하거나 데이터를 다르게 해서 다른 버전을 가지고 있는 경우라면, AssetBundle Variants를 대신 사용하는 것을 고려해보세요.

일단 위의 가이드라인을 지키고 나면, 특정 시점에 에셋번들의 컨텐츠의 50% 미만이 사용된다면 이 에셋번들을 나누는 것을 고려해 보세요. 또한 동시에 로드되는 작은 에셋번들(5~10개 미만)을 결합하는 것도 고려해 보세요.

4.3.1. 논리적인 실체로 그룹핑(Logical entity grouping)

논리적 실체로 그룹핑하는 방법은 프로젝트의 기능적인 부분에 기반하여 오브젝트를 그룹핑하는 전략입니다. 이 전략을 채용하면, 애플리케이션의 다른 부분은 다른 에셋번들로 분리되게 됩니다.

예 :

  • 하나의 UI 화면에 있는 텍스쳐와 레이아웃 데이터를 모두 같이 묶으세요.
  • 한 셋트의 캐릭터에 사용되는 텍스쳐, 모델, 애니메이션을 모두 같이 묶으세요.
  • 여러 레벨에 거쳐서 공유되는 텍스쳐와 모델을 모두 같이 묶으세요.

논리적 실체 그룹핑은 가장 흔한 에셋번들 전략이고, 이는 특히 다음과 같은 경우에 적합합니다 :

  • DLC3
  • 애플리케이션의 생명주기 내내 많은 곳에서 등장하는 실체들

예 :

  • 공통 캐릭터나 기본 UI 요소들
  • 플랫폼이나 성능 설정에 기반해서 단독으로 변하는 실체들

논리적 실체에 의해 에셋을 그룹핑하는 것의 장점은 변하지 않은 컨텐츠를 재 다운로드할 필요없이 쉽게 개별 실체를 업데이트할 수 있다는 것 입니다. 이러한 이유 때문에 이 전략이 특히 DLC에 맞다는 것 입니다. 이 전략은 또한 가장 메모리 효율적인 경향도 있는데, 애플리케이션이 현재 사용하는 실체에 대한 에셋번들만을 로드하기 때문입니다.

하지만, 이는 구현하기에 가장 다루기 어려운 전략입니다. 왜냐하면 에셋번들에 오브젝트를 할당하는 개발자가 정확히 어떻게, 그리고 언제 각 개별 오브젝트가 프로젝트에서 사용되는지에 익숙해야하만 하기 때문입니다.

4.3.2. 타입 그룹핑(Type Grouping)

타입 그룹핑은 가장 간단한 전략입니다. 이 전략을 쓰면, 비슷하거나 같은 타입의 오브젝트들은 같은 에셋번들에 위치하게 됩니다. 예를 들어, 다른 오디오 트랙을 에셋번들에 넣거나, 다른 언어 파일을 에셋번들에 넣는 식으로 말이죠.

이 전략이 간단하기는 하지만, 빌드 시간, 로드 시간, 업데이트라는 측면에서는 종종 가장 비효율적 입니다. 이는 로컬라이지이션 파일같이 작고 동시에 업데이트되는 파일에 가장 자주 쓰입니다.

4.3.3. 동시 컨텐츠 그룹핑(Concurrent content grouping)

동시 컨텐츠 그룹핑은 동시에 로드되고 사용되는 컨텐츠를 하나의 에셋번들에 넣는 전략입니다. 이 전략은 프로젝트의 컨텐츠가 매우 지역적인 경우(컨텐츠가 특정 위치나 시간 외에 거의 나타나지 않는 경우)에 가장 일반적으로 사용됩니다. 예로는 각 레벨마다 유일한 아트, 캐릭터, 사운드 이펙트를 사용하는 레벨 기반의 게임이 있을 수 있겠네요.

동시 컨텐츠 그룹핑을 수행하는 가장 흔한 방법은 씬에 기반한 에셋번들을 만드는 것인데, 각 씬에 기반한 에셋번들은 씬의 전체 또는 대부분의 의존성을 포함하고 있습니다.

컨텐츠가 강하게 지역적이지 않은 프로젝트에서, 그리고 애플리케이션의 생명주기 동안 컨텐츠가 나오는 위치가 바뀌는 경우에는 동시 컨텐츠 그룹핑은 논리적 실체 그룹핑으로 수렴하게 됩니다. 둘 다 에셋번들의 컨텐츠의 효율을 최대화하는데에 필수적인 전략입니다.

이러한 시나리오의 예로는 오픈 월드 게임이 있을 수 있습니다. 오픈 월드 게임에서 캐릭터는 랜덤하게 생성되고 월드 공간에 분포됩니다. 이러한 경우에, 어떤 캐릭터들이 동시에 나타날지 예측하기 쉽지 않고, 따라서 캐릭터들은 일반적으로 다른 전략을 이용해서 그룹핑 되어야 합니다.

4.4. 에셋번들로 패치하기

에셋번들로 패칭하는 것은 새로운 에셋번들을 다운로드하고 기존 것을 바꿔치는 것 정도로 간단합니다. 만약 애플리케이션의 캐쉬된 에셋번들을 관리하기 위해 WWW.LoadFromCacheOrDownload 나 UnityWebRequest 를 사용한다면, 이 API에 다른 버전 인자를 넘기면 됩니다. (자세한 내용을 알고 싶으시면 위의 링크를 보세요.)

패치 시스템에서 풀어야 할 더 어려운 문제는 어떤 에셋번들을 변경해야하는지 파악하는 것 입니다. 패치 시스템에는 두 정보 리스트가 필요합니다 :

  • 현재 다운로드된 에셋번들의 리스트와 이들의 버전 정보
  • 서버에 있는 에셋번들의 리스트와 이들의 버전 정보

패쳐(patcher)는 서버에 있는 에셋번들의 리스트를 다운로드하고 이미 있는 에셋번들 리스트와 비교해야 합니다. 없는 에셋번들이나, 버전 정보가 바뀐 에셋번들은 다시 다운로드되어야 합니다.

유니티 5의 에셋번들 시스템은 빌드가 끝나면 하나의 추가적인 에셋번들을 생성합니다. 이 여분의 에셋번들은 AssetBundleManifest 오브젝트를 포함하고 있습니다. 이 매니페스트 오브젝트는 에셋번들의 리스트와 그들의 해쉬 값을 포함하고 있고, 클라이언트에 가능한 에셋번들의 리스트와 버전 정보를 전달하는 데에 사용될 수 있습니다. 에셋번들 매니페스트 번들에 대한 더 자세한 내용을 알고 싶으시면 유니티 매뉴얼을 참고하세요.

에셋번들의 변화를 탐지하기 위한 커스텀 시스템을 작성하는 것도 가능합니다. 커스텀 시스템을 만드는 대부분의 개발자는 에셋번들 파일 리스트를 위한 산업 표준(industry-standard) 데이터 포맷을 사용해야하는데, 여기에는 JSON같은 것이 있고, 체크섬을 계산하기 위해 표준 C# 클래스를 사용하는데 예를 들면 MD5가 있습니다.

4.4.1. 차이 패치 (Differential patching)

유니티 5에서, 유니티는 결정론적인 방법으로 데이터가 정렬된 에셋번들을 빌드할 수 있습니다. 이는 애플리케이션에의 커스텀 다운로더에서 차이 패치를 구현 가능케 합니다. 에셋번들을 결정론적인 레이아웃으로 빌드하기 위해, BuildAssetBundles API를 호출할 때, BuildAssetBundleOptions.DeterministicAssetBundle 플래그를 전달하십시오.

유니티는 차이 패치를 위한 내장된 매커니즘을 제공하지 않고 있고, WWW.LoadFromCacheOrDownload 나 UnityWebRequest 중 어떤 것도 내장 캐시 시스템을 쓸 때 차이 패치를 수행하지 않습니다. 만약 차이 패치가 필요하다면, 커스텀 다운로더를 작성해야 합니다.

4.4.2. iOS On-Demand Resources

On-Demand Resources 는 컨텐츠를 iOS와 TVOS 장치에 제공하기 위한 애플 API입니다. 이는 iOS 9 장치에서 쓸 수 있습니다. 이 기능은 앱스토어에 런치하기 위해 현재 요구되는 조건은 아니지만, TVOS 앱에서는 런치에 필요한 요구조건 입니다.

애플의 On-Demand Resources 시스템에 대한 일반적인 아웃라인은 애플 개발자 사이트에서 찾아 볼 수 있습니다.

유니티 5.2.1 에서, App Slicing과 On-Demand Resources 둘 다에 대한 지원은 Asset Catalogs라는 다른 애플 시스템에 만들어져 있습니다. 유니티 에디터에서 콜백을 등록한 뒤에 iOS 애플리케이션을 위한 빌드 파이프라인은 Asset Catalogs에 자동으로 위치할 파일들을 보고하고 특정 On-Demand Resources 태그를 할당합니다.

새로운 UnityEngine.iOS.OnDemandResources API는 런타임에 On-Demand Resources 파일을 가져오고 캐쉬할 수 있는 기능을 제공합니다. 일단 자원을 ODR을 통해 가져왔다면, 이 자원은 AssetBundle.LoadFromFile API를 이용해서 유니티에 로드될 수 있습니다.

더 자세한 내용과 예제 프로젝트를 보고 싶다면 이 유니티 포럼의 글을 참고 하세요.

4.5. 일반적인 실수

이 섹션에서는 에셋번들을 사용하는 프로젝트에서 일반적으로 나타나는 몇몇 문제에 대해 다룹니다.

4.5.1. 에셋 중복

유니티 5의 에셋번들 시스템은 어떤 오브젝트가 에셋번들 안에 들어 있다면, 이 오브젝트에 대한 모든 의존 관계를 가려버립니다. 이는 Asset Database를 사용할 때 발생합니다. 이러한 의존 관계 정보는 하나의 에셋번들에 포함될 오브젝트들을 결정하는 데에 사용됩니다.

하나의 에셋번들에 명시적으로 할당된 오브젝트들은 그 에셋번들에만 빌드될 것 입니다. 오브젝트가 “명시적으로 할당되었다”라는 것은 오브젝트의 에셋 임포터가 assetBundleName 속성을 비어있지 않은 스트링으로 가지고 있을 때를 말합니다. 이는 유니티 에디터의 해당 오브젝트 인스펙터에서 에셋번들을 선택하거나 에디터 스크립트에서 선택함으로써 이루어집니다.

만약 두 개의 다른 오브젝트가 각각 다른 에셋번들에 할당되었는데, 둘다 공통의 오브젝트에 대한 참조를 가지고 있다면, 그 의존 관계의 오브젝트는 두 에셋번들에 복제되게 됩니다. 복제된 의존 관계의 오브젝트는 인스턴스화 되는데, 이는 두 개의 복제된 의존 관계 오브젝트가 다른 식별자를 가진 별개의 오브젝트로 간주됨을 의미합니다. 이는 애플리케이션의 에셋번들 총 크기를 증가시킬 것 입니다. 또한 애플리케이션에서 이 두개의 오브젝트를 로드하게 되면 두개의 다른 복제본이 메모리에 로드되게 됩니다.

이를 해결하기 위한 몇 가지 방법이 있습니다 :

  1. 다른 에셋번들에 빌드될 오브젝트들은 의존 관계를 공유하지 않도록 하십시오. 의존 관계를 공유하는 어떤 오브젝트들이 있다면, 이들은 같은 에셋번들에 넣음으로써 의존 관계의 복제를 막을 수 있습니다.
    • 이러한 방법은 일반적으로 많은 의존 관계를 공유하는 프로젝트에는 적합하지 않습니다. 이런 경우에는 단일 에셋번들이 생겨서 엄청나게 자주 재빌드되고 재 다운로드되는 일이 생길 수 있습니다.
  2. 의존 관계를 공유하고 있는 어떠한 두 개의 에셋번들도 동시에 로드될 수 없도록 에셋번들을 분할 하십시오.
    • 이러한 방법은 레벨 기반의 게임같은 타입의 프로젝트에서 잘 동작할 수 있습니다. 하지만, 이는 여전히 프로젝트의 에셋번들 크기를 불필요하게 늘리고, 빌드 시간과 로딩 시간 또한 늘립니다.
  3. 모든 의존 관계 에셋들을 그들 만의 에셋번들에 빌드하십시오. 이는 전적으로 중복된 에셋에 대한 위험 부담을 제거할 수 있지만, 복잡성을 증가시킵니다. 애플리케이션은 에셋번들 간의 의존 관계를 추적해야만 하고, 어떤 AssetBundle.LoadAsset API를 호출하기 전에 적절한 에셋번들들이 로드된 상태를 보장해야만 합니다.

유니티 5에서, UnityEditor 네임스페이스에 있는 AssetDatabase API를 통해 오브젝트 간의 의존 관계를 추적할 수 있습니다. 네임스페이스가 함축하고 있듯이, 이 API는 런타임이 아닌 유니티 에디터에서만 사용 가능합니다. AssetDatabase.GetDependencies는 특정 오브젝트나 에셋의 모든 직접적인 의존 관계를 얻는 데에 사용될 수 있습니다. 이러한 의존 관계에는 스스로에 대한 의존 관계도 포함되어 있음을 기억하십시오. 추가로, AssetImporter API는 에셋번들에 어떤 특정 오브젝트가 할당됐는지 확인하기 위해 사용될 수 있습니다.

AssetDatabase와 AssetImporter API를 조합하면, 에셋번들의 모든 직접 또는 간접 의존 관계가 어떤 에셋번들에 할당되도록 하거나, 의존 관계를 공유하는 두 개의 에셋번들이 존재하지 않도록 하는 에디터 스크립트를 작성하는 것이 가능합니다. 중복되는 에셋의 메모리 비용 때문에, 모든 프로젝트에서 그러한 스크립트를 사용하기를 추천드립니다.

4.5.2. 스프라이트 아틀라스 중복

이어지는 섹션에서는 유니티 5의 에셋 의존 관계 계산 코드가 자동 생성되는 스프라이트 아틀라스과 함께 사용될 때의 변덕에 대해 다룹니다. 유니티 5.2.2p4와 유니티 5.3에서는 이런 동작을 해결하기 위한 패치가 이루어 졌습니다.

유니티 5.2.2p4과 5.3 그리고 그 이상

자동 생성된 스프라이트 아틀라스는 자신이 생성된 스프라이트 오브젝트를 포함하고 있는 에셋번들에 할당됩니다. 만약 그 스프라이트 오브젝트가 여러 개의 에셋번들에 할당되어 있다면, 스프라이트 아틀라스는 하나의 에셋번들에 할당되지 않고 중복되게 됩니다.

스프라이트 아틀라스가 중복되지 않게 하려면, 같은 아틀라스로 태그된 모든 스프라이트가 같은 에셋번들에 할당되도록 하십시오.

유니티 5.2.2p3와 그 이하

자동 생성된 스프라이트 아틀라스는 절대 어떤 한 에셋번들에 할당되지 않습니다. 이 때문에, 이 아틀라스는 이를 구성하고 있는 스프라이트가 포함된 에셋번들과, 이 스프라이트들이 참조하고 있는 에셋번들에도 포함되어 집니다.

이러한 문제때문에, 유니티의 스프라이트 패커를 사용하는 모든 유니티 5 프로젝트는 유니티 5.2.2p4나 5.3 또는 그 이상의 새로운 버전으로 업그레이드하기를 강력하게 추천하는 바입니다.

업그레이드할 수 없는 프로젝트의 경우에는 2가지 회피 방법이 있습니다 :

  1. 쉬운 방법 : 유니티의 내장 스프라이트 패커를 사용하지 마십시오. 외부 툴을 이용해서 생성하는 스프라이트 아틀라스는 일반 에셋이 되고, 에셋번들에 적절히 할당될 수 있습니다.
  2. 어려운 방법 : 자동 아틀라스 스프라이트를 사용하는 모든 오브젝트를 스프라이트와 같은 에셋번들에 할당하십시오.
  3. 이는 생성된 스프라이트 아틀라스를 다른 에셋번들의 간접 의존 관계로 보지 않기 때문에 중복되지 않을 것 입니다.
  4. 이 해결책은 유니티 스프라이트 패커를 사용하는 단순한 워크 플로우를 보존하지만, 에셋을 다른 에셋번들로 분리시킬 수 있는 능력을 감소시키고, 아틀라스가 변하지 않더라도 아틀라스를 참조하는 어떤 컴포넌트의 데이터 변화가 발생하면 전체 스프라이트 아틀라스를 다시 다운로드해야만 합니다.

4.5.3. 안드로이드 텍스쳐

안드로이드 생태계의 심각한 디바이스 파편화 때문에, 텍스쳐를 몇몇 다른 포맷으로 압축하는 것은 종종 필수적입니다. 모든 안드로이드 장치가 ETC1을 지원하지만, ETC1은 텍스쳐의 알파 채널을 지원하지 않습니다. 애플리케이션이 OpenGL ES 2 지원을 필요로하지 않는다면, 이 문제를 해결하기 위한 가장 명확한 방법은 ETC2를 사용하는 것이고, 이는 OpenGL ES 3를 지원하는 모든 안드로이드 장치에서 사용 가능합니다.

대부분의 애플리케이션은 ETC 지원이 안되는 오래된 기종에도 설치할 필요가 있습니다. 이를 해결하기 위한 하나의 방법은 유니티 5의 AssetBundle Variants를 사용하는 것 입니다. (다른 옵션에 대한 자세한 내용을 알고 싶으면, 유니티의 안드로이드 최적화 가이드를 봐주세요.)

AssetBundle Variants를 사용하기 위해서, 확실하게 ETC1으로 압축할 수 없는 모든 텍스쳐들은 텍스쳐만 있는 에셋번들로 분리해야 합니다. 다음으로, 안드로이드 생태계에서 ETC2를 수용할 수 없는 장치를 지원하기 위해 충분한 에셋번들 variants를 만들어야 합니다. 이 때 제조사 특유의 압축 포맷을 사용할 수 있는데, DXT5, PVRTC, ATITC 등이 있습니다. 각 에셋번들 variant에 대해서, 포함된 텍스쳐의 TextureImporter 셋팅의 압축 포맷을 variant에 적절하게 변경하십시오.

런타임에, 다른 텍스쳐 압축 포맷에 대한 지원은 SystemInfo.SupportsTextureFormat API를 이용해서 탐지할 수 있습니다. 이 정보는 지원되는 포맷으로 압축된 텍스쳐를 포함하고 있는 에셋번들 Variant를 선택하고 로드하는데에 사용될 수 있습니다.

안드로이드 텍스쳐 압축 포맷에 대한 더 자세한 내용은 여기에서 보실 수 있습니다.

4.5.4. iOS 파일 핸들 남용

이 섹션에 나오는 내용은 유니티 5.3.2p2에서 수정되었습니다. 현재 유니티 버전은 이 이슈에 영향을 받지 않습니다.

유니티 5.3.2p2 이전 버전에서는, 에셋번들이 로드되어 있는 전체 시간 동안 유니티가 그 에셋번들에 대한 파일 핸들을 유지하고 있었습니다. 이는 대부분의 플랫폼에서는 문제가 되지 않습니다. 하지만, iOS에서는 한 프로세스에서 동시에 열 수 있는 파일 핸들의 수가 255개로 제한되어 있습니다. 만약 에셋번들을 로드하다가 이러한 제한을 초과하게 되면, “Too Many Open File Handles” 에러를 내뿜으로 로딩 콜이 실패하게 됩니다.

이는 컨텐츠를 수백개 또는 수천개의 에셋번들로 나누려고하는 프로젝트에서 흔히 발생할 수 있는 문제였습니다.

패치된 버전의 유니티로 업그레이드할 수 없는 프로젝트에서는, 임시 방편이 있습니다 :

  • 관련된 에셋번들을 합침으로써 사용 중인 에셋번들의 수를 줄이는 방법이 있습니다.
  • 에셋번들의 파일 핸들을 닫기 위해 AssetBundle.Unload를 사용하는 것과 로드된 오브젝트의 생명주기를 수동으로 관리하는 방법이 있습니다.

4.6. 에셋번들 Variants

유니티 5의 에셋번들 시스템의 주요 특징은 AssetBundle Variants의 도입입니다. Variants의 목적은 애플리케이션을 런타임 환경에 더 잘 맞게 컨텐츠를 조절하도록 하는 것 입니다. 오브젝트를 로딩하고 Instance ID 참조를 풀 때, Variants는 다른 에셋번들 파일에 있는 다른 UnityEngine.Object를 마치 “같은” 오브젝트 인 것 처럼 보이게 해줍니다. 개념상, 이는 두 개의 UnityEngine.Object가 같은 File GUID와 Local ID를 공유하는 것처럼 보이게 해주고, 스트링 Variant ID를 이용해 로드할 실제 UnityEngine.Object를 식별합니다.

이 시스템의 2가지 주된 사용 사례가 있습니다 :

  1. Variants는 정해진 플랫폼에 대해 적절한 에셋번들을 로딩하는 것을 간단하게 해줍니다.
    • 예 : 빌드 시스템은 DirectX 11 윈도우 빌드를 위한 높은 해상도의 텍스쳐와 복잡한 셰이더를 포함하는 에셋번들과 안드로이드를 위한 더 낮은 정밀도의 컨텐츠를 포함하는 에셋번들을 빌드할 수 있습니다. 런타임에, 프로젝트의 리소스 로딩 코드는 플랫폼에 맞는 에셋번들 Variant를 로드할 수 있고, [AssetBundle.Load] API에 넘겨질 오브젝트 이름은 동일하게 사용할 수 있습니다.
  2. Variants는 같은 플랫폼이지만 하드웨어가 다른 경우, 다른 컨텐츠를 로드할 수 있도록 해줍니다.
    • 이 것이 넓은 범위의 모바일 장치들을 지원할 수 있는 핵심입니다. iPhone 4는 iPhone 6에서 보여주는 정밀도의 컨텐츠를 보여줄 수 없습니다.
    • 안드로이드에서, 에셋번들 Variants는 장치들 간의 엄청난 화면 비율과 DPI에 대한 파편화를 해결하는데에 사용될 수 있습니다.

4.6.1. 한계

에셋번들 Variant 시스템의 핵심 한계는 구별되는 에셋으로부터 Variants를 빌드할 필요가 있다는 점 입니다. 이 한계는 에셋의 임포트 셋팅에 대한 차이만으로도 적용 가능합니다. 만약 Variant A와 Variant B의 차이가 유니티 텍스쳐 임포터에서 설정한 텍스쳐 압축 알고리즘이 유일한 차이라면, Variant A와 Variant B는 완전히 다른 에셋입니다. 이는 Variant A와 Variant B는 디스크 상에서 별도 파일로 저장되어야 함을 의미합니다.

이러한 한계점은 소스 컨트롤에 의해 유지되어야 하는 특정 에셋에 대한 여러 개의 복제본을 가지는 큰 프로젝트의 관리를 복잡하게 만듭니다. 개발자가 그 에셋의 컨텐츠를 바꾸려고 하면, 해당 에셋의 모든 복제본이 업데이트 되어야 합니다.

이 문제에 대해 내장된 회피 수단은 존재하지 않습니다.

대부분의 팀은 그 팀만의 에셋번들 Variants 형태를 구현합니다. 이는 에셋번들의 파일명 뒤에 잘 정의된 접미사를 붙임으로써 할 수 있습니다. 이 때 접미사를 붙이는 이유는 그 에셋번들에 특정 variant를 식별하기 위함입니다. 커스텀 코드는 이러한 에셋번들을 빌드할 때, 포함되는 에셋의 임포터 셋티을 프로그램적으로 변경합니다. 일부 개발자들은 프리팹에 붙어있는 컴포넌트의 인자를 변경하기 위해 자신의 커스텀 시스템을 확장하기도 합니다.

4.7. 압축 또는 비압축?

에셋번들을 압축할지 말지에 대해서는 신중한 고려가 필요합니다. 중요한 질문은 다음과 같습니다 :

  • 에셋번들의 로딩 시간이 중요한 요소인가? 로컬 저장 공간이나 로컬 캐쉬에서 로딩할 때, 비압축 에셋번들이 압축 에셋번들보다 훨씬 빠르게 로드됩니다. 원격 서버에서 압축된 에셋번들을 다운로드하는 것은 일반적으로 압축되지 않는 에셋번들을 다운로드하는 것보다 빠릅니다.
  • 에셋번들의 빌드 시간이 중요한 요소인가? LZMA와 LZ4는 파일을 압축할 때 매우 느리고, 유니티 에디터는 에셋번들을 순차적으로 처리합니다. 에셋번들의 개수가 많은 프로젝트는 이들을 압축하는데에 많은 시간이 걸립니다.
  • 애플리케이션의 크기가 중요한 요소인가? 만약 애플리케이션에 에셋번들이 포함된 상태로 전달된다면, 에셋번들을 압축하는 것이 애플리케이션의 전체 사이즈를 줄일 수 있습니다. 대안으로, 에셋번들을 설치 후에 다운로드할 수도 있습니다.
  • 메모리 사용량이 중요한 요소인가? 유니티 5.3 이전에, 모든 유니티의 압축 해제 메커니즘은 압축을 해제하기 전에 압축된 에셋번들 전체를 메모리에 로드할 필요가 있었습니다. 만약 메모리 사용량이 중요하다면, 비압축이나 LZ4로 압축된 에셋번들을 사용하세요.
  • 다운로드 시간이 중요한 요소인가? 에셋번들이 크거나, 모바일에서 3G로 다운로드하는 것처럼 대역폭이 제한적인 환경에 유저가 처해져 있는 등의 경우에 압축이 필요할 지도 모릅니다. 만약 빠른 속도로 연결되어 있는 PC에 수십 MB의 데이터를 전달하는 경우라면, 압축을 생략하는 것도 가능합니다.

4.8. 에셋번들과 WebGL

유니티는 WebGL 프로젝트에서는 압축된 에셋번들을 사용하지 말 것을 강력히 추천합니다.

유니티 5.3에서, WebGL 프로젝트에서 모든 에셋번들의 압축 해제와 로딩은 메인 쓰레드에서 발생합니다. 이는 유니티 5.3의 WebGL 익스포트 옵션에서는 워커 쓰레드를 지원하지 않기 때문입니다. ( 에셋번들의 다운로드는 브라우저로 위임되어 XMLHttpRequest 자바스크립트 API를 통해 이루어지고, 유니티의 메인 쓰레드 밖에서 발생할 것 입니다.) 이는 WebGL에서 압축된 에셋번들을 매우 비쌈을 의미합니다.

대게는 에셋번들을 압축하지 않은 상태로 빌드하고, 애플리케이션의 컨텐츠를 제공하는 웹 서버가 에셋번들을 gzip 압축 형태로 제공하도록 설정하는 것이 훨씬 빠릅니다. gzip 압축이 LZMA와 같은 압축률을 낼 수는 없지만, 에셋번들은 브라우저에 의해 워커 쓰레드에서 압축 해제 되고, 유니티 애플리케이션은 에셋번들의 압축을 해제하는 동안 오랫 동안 멈춰있는 경험을 하지 않아도 됩니다.


  1. over-the-air download size limit을 무선 다운로드 크기 제한이라 번역했는데요, 아마 iOS나 안드로이드에서 통신사 네트웍으로 연결되어 있을 때 다운로드 받을 수 있는 최대 크기 제한이 아닐까 추측합니다.
  2. quirk를 변덕이라 번역하는게 맞는 것인지 잘 모르겠네요. 문맥상으로는 결함 비슷한 것 같긴 합니다.
  3. DLC – 본문에는 설명이 없지만 DownLoadable Contents를 의미하는 것 같습니다.

출처: https://code-kooc.tistory.com/entry/번역-에셋번들-사용-패턴 [코드쿡]

Grdle 빌드변형(빌드 타입, 앱 서명 첨부하기, 제품 특성)

 Gradle을 도입한 목표중 하나는 단일 소스 코드로 목적에 맞는 다양한 APK 생성입니다. 모듈 내부에서 디버그, 릴리즈와 같은 빌드 타입별로 세부사항을 변경하거나, lite, full 버전 과 같이 기능 일부를 비활성화 할 수 있습니다.(feature 변경) 
 빌드 변형은 빌드타입과 제품특성을 합한 개념입니다. 어떤 모듈에 3가지 빌드타입과 4가지 제품 특성이 존재한다면 빌드 변형은 3*4=12가지 경우입니다. 이번 포스팅에서 빌드타입에 대해 살펴보도록 하겠습니다.

빌드타입

build type 에는 debug와 release가 존재합니다. 디버깅이 포함된 apk이냐 마켓에 배포할 apk냐에 따라 구분되어집니다. 
debug build는 default대로 진행하면 되고, release 빌드때는 pro guard 를 비활성화 하였습니다.

 buildTypes {
    release {
        minifyEnabled true
        proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
    }
}

다음과 같이  minifyEnabled true로 하면 프로가드가 활성화됩니다. 소스코드난독화하여 역컴파일을 방지하게 됩니다.
프로젝트 폴더에들어가서 gradlew :app:assemble 명령을 치면 빌드가 완료되고나서 debug, release apk 가 생성됨을 확인하실 수 있습니다.
또한, 그렇게 하기 귀찮으시면 gradle 콘솔(안드로이드 프로젝트)을 열어서 assemble 태스크를 실행하시면 되겠습니다. app모듈에 build 그룹에 있습니다.

다음과 같이 assemble task를 실행시키고나면, debug용도와 release용도의 apk가 생성됨을 확인하실 수 있습니다.

앱 서명 첨부하기

release 용을 빌드할떄는 앱서명(siging)에 관한정보를 직접 지정하여 등록하셔야 합니다. signing을 할때 module build.gradle을 수정하는데, android 블록아래에 signingConfigs를 입력합니다. 물론 siginingConfigs는 buildTypes 블록보다 먼저 정의되어야 합니다.

signingConfigs{
    release{
        storeFile file('app.keysave')
        storePassword 'keypass'
        keyAlias 'key'
        keyPassword 'mypassword'
    }
}


buildTypes {
    release {
        signingConfig signingConfigs.release
        minifyEnabled false
        proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
    }
}

그런데 문제는, 앱서명에 관한정보를 직접 썼다는게 보안상의 문제로 될수있습니다. 그러므로 담당자 서버의 환경변수에 별도로 지정하는게 좋겠습니다.

signingConfigs{
    release{
        storeFile file('app.keysave')
        storePassword System.getenv("KEYSTORE_PASSWORD")
        keyAlias System.getenv("KEY_ALIAS")
        keyPassword System.getenv("KEY_PASSWORD")
    }
}

그리고 환경변수를 윈도우에서 설정해줍시다. 예로들어 KEYSTORE_PASSWORD라는 환경변수를 읽어오기 위해서는 ORG_GRADLE_PROJECT_KEYSTORE_PASSWORD 라고 환경변수 이름을 짓고, 변수값을 넣어서 생성하면 gradle에서 환경변수로 값을 얻어오실 수 있습니다.

제품 특성

빌드타입이 빌드의 속성을 변경하는것이라고 앞서 설명드렸습니다. 제품 특성은 리소스 교체 혹은 특정 feature를 활성화 또는 비활성화 시킬 수 있습니다. module build.gradle에 기술하시면 되겠습니다.

제품 특성 생성 해보기

제품 특성 생성하려면 android 블록에 productFlavor 블록을 추가합니다. lite와 full이라는 제품 특성 추가해보겠습니다.

productFlavors{
    lite{
        applicationId 'com.lite.HelloWorld'
    }
    full
}

.

제품 특성 확인

추가된 제품 특성을 확인하려면 Android Studio IDE 좌측 하단에 세로방향으로 위치한 Build Variants 창을 활용합니다. app모듈에 build Varient를 선택해보시면, full,lite 조합 release, debug로 나오는것은 확인하실수있습니다. 2*2 =4가지경우가있네요. 원하시는 값을 선택후 build apk를 하였더니 아래그림처럼, app-gull-release apk가 생성됨을 확인할 수 있었습니다.

제품 특성 활용

제품특성을 간단히 적용하려면 AndroidManifest.xml 을 변경한후 재빌드 하면 되긴합니다. 엄청 번거롭죠. 그러나 제품특성을 활용하면 더욱 쉽고 간편하게 적용시킬 수 있습니다. 예를든다면 lite 버전의 로고와 app label을 변경합니다.  app 모듈의 src폴더 아래 lite 폴더를 만들고 AndroidManifest.xml 파일을 추가합니다. Project View로 진행하시면 됩니다.

프로젝트뷰로 다음과 같이 lite폴더에 AndroidManifest.xml 파일을 추가하였습니다.
그리고 Android 뷰로 돌아오면 lite버전의 AndroidManifest.xml 파일이 추가된것을 확인하실 수 있습니다. 추가된 파일에는 (lite)라고 추가됨을 확인하실 수 있습니다. 
제품특성은 module build.gradle의 android.defaultConfig블록의 속성값을 공유하게 됩니다. defaultConfig블록은 간단히 요약하면 AndroidManifest.xml 의 내용 중 gradle로 재정의할 수 있는 속성들입니다. 또한 제품특성은 소스 코드를 재정의할 수 있습니다. 변경량은 최소화 하는게 좋습니다.

제품 특성으로 특정기능 활성화 (Feature On)

 제품특성을 활용하면 전체 app,의 기능 중 일부를 활성화 또는 비활성화 할 수 있습니다. 예를들어 full 제품 특성에서는 현재 시간 표시 기능을 활설화 하고 demo제품 특성에서는 현재시간을 보여주지 않게 합니다. 모듈 build.gradle에서 BuildConfig변수를 활용하면 됩니다.

productFlavors{
   full{
  buildConfigField "boolean", "SHOW_CURRENT_TIME", "true"
  }
   lite{
   buildConfigField "boolean", "SHOW_CURRENT_TIME", "false"
  }
}

SHOW_CURRENT_TIME이라는 boolean 변수를 추가했습니다. 내용을 입력하고 SYNC NOW버튼을 누르면 변수가 자동으로 생성되어 소스코드에서 참조할 수 있습니다.

 if(BuildConfig.SHOW_CURRENT_TIME){
}

실제확인하려면 app/build/generated/source/buildConfig/lite/debug/com/프로젝트 폴더에 BuildConfig클래스를 열어봅니다. applicationid등 빌드스크립트가 생성한 다양한 변수가 있는것을 확인하실 수 있습니다.

안드로이드 Gradle Test

Android application Test 방법

크게 2가지가 있습니다. 안드로이트 테스트(Instrumentation Test)와 Local PC의 JVM을 활용하는 새로운 개념의 Local Unit Test 입니다. 

참고) 로컬 유닛 테스트는 로컬 JVM을 활용하기 때문에 타깃 디바이스와 연결피 필요없습니다. 그리고 테스트 코드의 전체 실행속도가 향상되는 효과가 있습니다. 안드로이드 테스트에서는 APK를 생성하여 타깃 디바이스에 설치하고 실행하는 과정에서 시간 소모가 많은 편입니다.
Gradle을 활용화여 안드로이드 테스트 코드와 로컬 유닛테스트코드를 실행하는 방법을 알아보겠습니다.

로컬 유닛 테스트

app module의 build.gradle을 보시겠습니다. dpendencies부분에 아래와 같이 testCompile이 웬만하면 디폴트로 지정되어있으실겁니다.

dependencies {
    compile fileTree(dir: 'libs', include: ['*.jar'])
    testCompile 'junit:junit:4.12'
    compile 'com.android.support:appcompat-v7:26.0.0-alpha1'
}

그리고 ExampleUnitTest 클래스를 보시면 아래와 같이 코드가 미리작성되어있는것을 확인하실 수 있습니다. Junit4에서는 테스트코드에 @Test라는 애너테이션을 부착합니다.

public class ExampleUnitTest {
    @Test
    public void addition_isCorrect() throws Exception {
        assertEquals(4, 2 + 2);
    }
}

해당 unitTest 클래스 오른쪽 마우스로누르고, run “ExampleUnitTest” 선택시 정상적으로 테스트됩니다.
로컬유닛테스트를 실행할때 호출되는 중요한 Gradle Task는 다음과 같습니다.

:app:mockableAndroidJar UP-TO-DATE
:app:compileDebugUnitTestSources UP-TO-DATE

1. mock(app:mockableAndroidJar ) 테스크는 안드로이드의 android.jar 파일을 mock 인터페이스로 컴파일합니다. 로컬 유닛 테스트가 동작하려면 안드로이드 코드에 대한 mock 인터페이스가 필요합니다. mock 인터페이스 호출 시, 가짜객체를 주입하는 경우도 있습니다. 가짜 객체(mock object)를 이용한 테스트 기법은 구글문서를 참고하십시오.
mock 테스트 실행결과는 /build/generated/mockable-android-26.jar(26은 버전명) 입니다. 
2. complieDebugUnitTestSources 태스크입니다. 태스크 이름자체대로 디버그 모드로 로컬 유닛 테스트 코드를 컴파일합니다. 릴리즈 모드일때는 릴리즈 이름에 맞게 테스크가 실행되는것을 확인하실 수 있습니다. 
또한, 콘솔에서도 테스트코드를 실행하실 수 있습니다. HTML로 결과가 출력되는데요.  프로젝트 폴더에서 cmd창을 연 후,
> gradlew :app:testDebug 
와같이 치신후 엔터를 누르고, app/build/reports/tests/debug 폴더에 테스트 결과를 확인하실 수 있습니다.

안드로이드 테스트

안드로이드 테스트코드는 Activity, Fragment, ButtonView, TextView 등 UI컴포넌트와, 유틸리티클래스와 같은 Java클래스 모두 테스트할 수 있습니다. 다만 에뮬레이터나, 타깃 디바이스에서 실행해야하는 제약이 있습니다 .
저는 MainActivityTest라는 클래스를 만들고 아래와 같이 코드작성 후, 실시해보겠습니다.

public class MainActivityTest extends ActivityInstrumentationTestCase2<MainActivity> {
    public MainActivityTest() {
        super(MainActivity.class);
    }


    public  void testHelloLabel(){
        Activity ac = getActivity();
}

app 모듈의경우 /app/src/androidTest/java 폴더에 위치하고있습니다. 실행하는 방법은 로컬 유닛테스트와 유사합니다.
오른쪽마우스 누르고, run “MainActivityTest” 를 실행합니다.
:app:compileDebugAndroidTestSources
이번에 실행된 gradle test는 unitTest가 아닌, AndroidTest로 바뀐것을 확인하실 수 있습니다.
콘솔에서도 역시 확인하실 수 있습니다.
gradlew :app:connectedAndroidTest + 엔터 치시면됩니다.gradle 콘솔창에 뜨듯이 진행후, 완료구문을 확인하실 수 있습니다.

Espresso 연동하기

Espresso는 Android Testing Support Library에 편입되었습니다. 강력한 테스팅 도구이죠. 연동하는 방법을 살펴보시겠습니다.
app모듈의 build.gradle의 내용 추가입니다.

defaultconfig{
  testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
}

그다음 denependencies블록에 추가할 내용입니다.

androidTestCompile 'com.android.support.test:runner:0.4.1'
androidTestCompile 'com.android.support.test.espresso:espresso-core:2.2.1'
compile 'com.android.support:appcompat-v7:23.0.1'

로컬 유닛 테스트의 제약사항

로컬 유닛테스트 같은 경우 andoir.util.Log 클래스에 대한, mock 인터페이스가 제공되지안항 오류를 낼때가있습니다.
이를 피하려면  모듈 build.gradle에 다음 내용을 추가해야합니다.

android{
  testOptions{
    unitTests.returnDefaultValues = true   
  }
}

근본적으로 해결하려면 PowerMock과 같은 라이브러리를 활용하여 log 클래스의 static 메서드를 위한 가짜 객체를 주입하는 방법이 있습니다. PowerMock 관련 문서를 참조하시기 바랍니다.

안드로이드 Gradle 멀티 프로젝트 사용

Gradle 에서는 프로젝트를 단일 프로젝트나 멀티 프로젝트로 구성할 수 있습니다. 멀티프로젝트는 하위 폴더에 여러개의 Module을 추가할 수 있습니다. Android Studio 는 프로젝트를 생성하면 자동으로 멀티 프로젝트로 구성되고, 하위에는 App Module이 추가되어 있습니다. 

Android Library Module 만들기

안드로이드 라이브러리 모듈은 com.android.libaray 플러그인이 필요하고, 그결과 AAR파일을 생성 할 수 있습니다. AAR파일은 JAR파일과 비슷해보일 수 있지만 다른점은, 하나의 앱처럼 안드로이드 화면을 포함할 수 있다는 점입니다. JAR 파일은 순수 로직만 포함할 수 있습니다. 하지만 AAR파일은 res폴더의 xml 이미지, 리소스등을 포함할 수 있기때문에 안드로이드 화면을 표현할수 있게 됩니다. 
다음은 안드로이드 라이브러리 모듈을 생성하는 과정입니다. 안드로이드 스튜디오에서 [File -> New -> New Module]을 선택합니다. 그 이후 안드로이드 라이브러리 모듈을 선택하고 이름은 MyLibModule이라고 하겠습니다.

그리고 build.gradle에 settings.gradle에 보시면 include된것을 확인하실 수 있습니다.
include ‘:app’, ‘:mylibmodule’
또한 build->makd module ‘mylibmodule‘ 을선택하면 aar파일이 생성되는것을 outputs 폴더에서 확인하실 수 있습니다.

Gradle 로컬 저장소 만드는 방법

상용프로젝트를 개발할때, 라이브러리를 gradle을 이용해서 참조해서 사용하는 경우가 있습니다. 그런데 jcenter나 maven center에 올려버리면 보안상 문제가 생길 우려가 엄청납니다. 그러므로 사내에 로컬 저장소를 만들어서 운영해야 할 것입니다.

공동작업을 위해서는 프로젝트 폴더 하위에 로컬 저장소를 두는것이 일반적입니다. 먼저 프로젝트 홈폴더를 환경변수에 등록하겠습니다. 
ORG_GRADLE_PROJECT_HOME 환경변수는 gradle 스크립트에서 $PROJECT_HOME으로 참조할 수 있습니다.
다음과 같이 Project의 build.gradle을 변경합니다.

 allprojects{
  reposotories{
    jcenter()     
    mavne{      
      url "file://($PROJECT_HOME)./myReposiroy"     
    }   
  }
} 

참조할 수 있는 로컬 저장소가 생성되었습니다. URL은 위의 “file://($PROJECT_HOME)./myReposiroy” 와 같습니다.
그다음 mylibmoudle을 빌드한 결과를 로컬 저장소에 업로드할 수 있도록 uploadArchives 블록을 정의합니다.
mylibmoudle 모듈의 build.gradle 파일 아래쪽에 다음 내용을 추가합니다.

apply plugin: 'maven'
group ='com.exam.mylibmodule' 
version = '1.0'
uploadArchives {
  repositories{
     mavenDeployer{
         repository( url: "file://($PROJECT_HOME)./myReposiroy")
      }
   }
}

업로드할 저장소의 URL과 그룹이름, 버전을 지정하면됩니다. group, version, repository url을 확인하실 수 있습니다.
uploadArchives 태스크를 실행해야합니다. 해당 프로젝트 폴더로 가서 gradlew:mylibmodule:uploadArchives 라고 치시면, 해당 태스크를 실행하고 .aar파일이 지정했던 repository에 생성되는것을 확인하실 수 있습니다. 
마지막으로 로컬저장소에 배포된 .aar파일을 참조하도록 하겠습니다. app모듈에서 build.gradle을 아래와 같이 변경하면 되겠습니다.

dependencies{
    compile 'com.exam.mylibmoudle:mylibmodule:1.0@aar' 
}

이제 app moudle에서 library module 의 액티비티를 직접 실행하실수있습니다. 라이브러리르 모듈을 참고하기 때문이죠.

Android Gradle 외부 라이브러리 추가

가장 기본적 Gradle 외부라이브러리 참조 방법

 app module의 dependencies 블록에 외부 라이브러리를 지정합니다. 외부 라이브러리를 지정할때 compile 명령을 사용합니다. 
http://fsd-jinss.tistory.com/36 앞서 생성했던 안드로이드 그래들 프로젝트에서의 app module gradle dependencies 부분을 살펴 보시겠습니다.

dependencies {
    compile fileTree(dir: 'libs', include: ['*.jar'])                    //1
    testCompile 'junit:junit:4.12'                                       //2
    compile 'com.android.support:appcompat-v7:26.0.0-alpha1'            //3
}

1. libs폴더의 JAR파일을 추가하는 방법입니다.
2. 테스트 빌드시 참조하는 라이브러리 입니다.
3. 로컬에 없는, 외부라이브러리를 참조합니다. Gradle 에서 외부 라이브러리를 참조하기 위해서는 다음 세가지 정보가 필요합니다.
– group Id : 라이브러리 패키지 이름이 필요합니다. (com.android.support)
– arifact id : 라이브러리 이름이 필요합니다. (appcompat)
– version   : 참조하는 버전명이 필요합니다. (v7:26.0.0-alpha1)

다른 모듈 소스코드 참조하기

아래는 라이브러리 모듈(library module, app module 말고요)의 소스코드 참조하는 방법입니다.

dependencies {
   compile project(':mylibrary') 
}

다음은 하위폴더에 위치한 라이브러리 모듈의 소스코드를 참조하는 방법입니다.
lib_modules 폴더 하위에 mylibrary가 위치한다면 다음과 같이 기술합니다.

dependencies {
   compile project(':lib_modules:mylibrary') 
 } 

디버그 모드와 릴리즈 모드 참조하기

 dependencies {
 debugCompile ‘com.abcd.test:myTest-android:1.3’
 releaseCompile ‘com.abcd.test:myTest_release-android:1.3’
}

디버그 컴파일과 릴리즈 컴파일로 구분합니다.

안드로이드 유닛 테스트 참조하기

dependencies {
    androidTestCompile 'junit:junit:4.12'
}

참고로 androidTestCompile은 안드로이드 UI 테스팅할때 사용하는 명령어로, testCompile과 구별됩니다.

공개라이브러리 참조하기 – JAR 파일

HTTP 클라이언트와 웹소켓 클라이언트를 지원하는 AndroidSync 참조 예입니다. github에 Gradle에서 간편하게 추가하는 방식을 소개하고있습니다.

 dependencies{
 compile 'com.koushikdutta.async:androidasync:2.+'
}

공개라이브러리 참조하기 – AAR 파일

AAR파일을 참조해야하는 경우가 있습니다.  @aar을 끝에 붙여주면 됩니다.

dependencies{
  complie 'com.abcd:andrid-xxx:1.0.1@aar'
}

로컬에 있는 AAR파일 참조하기

로컬에 AAR을 복사하여 참조하는 경우도 있을 수 있습니다. 이럴때는 libs 폴더에 복사후 gradle을 통해 참조할 수 있습니다. 

이때 module build.gradle 파일의 dependencies 블록만 변경해서는 안되고, repositories 블록도 함께 변경해야 합니다.

repositories{
    flatDirs{
        dirs 'libs'
    }
}

다음 예는 mylibrary 모듈의 패지 이름과 aar파일 예시이름 버전 1.0으로 로컬에 있는 경우 예입니다. 

dependencies{
  compile 'com.example.mylibray:myTest:1.0@aar'
}

.so(JNI)파일 참조하기

so 파일을 참조해야하는 경우도 있습니다. libs폴더 하위에 x86, armeabi-v7a 등 폴더를 만들고 그에맞는 .so파일을 복사하면 됩니다. (JNI 참고하세요)
아래와 같이 작성하시면 되겠습니다.
sourceSets는 안드로이드 앱을 gradle로 빌드하기 위해 사전에 약속된 폴더 구조입니다. 메인소스의 jniLibs.srcDirs 항목을 libs로 지정합니다.

android {
 sourceSets{
    main{
     jniLibs.srcDirs = ['libs']
    }
  }
}

그 외의 경우.

Gradle은 외부라이브러리르 가져올때, 그라이브러리가 내부적으로 참조하는 다른라이브러리도 함께 땡겨옵니다. 이럴 때exclude group 으로 특정 참조그룹을 제외시킬 수 도 있습니다. 현재 프로젝트와 의도지않게 가져오는 라이브러리의 버전 충돌문제로 사용하는 편입니다.

compile('xxx.xxx.xx:2.2.2'){<br>  <strong>exclude group:</strong> 'com.xxx' <br>}

transitive 옵션을 줄 수 있습니다. aar을 붙여 참조하게되면 transitive 옵션은 false 로 변경됩니다. (aar의 경우 그런것 같습니다.)
명시적으로 true값을 준다면, aar 파일이 필요로 하는 라이브러리를 추가로 땡겨 오게 됩니다.

compile('xxx.xxx.xx:2.2.2'){
 <strong>transitive </strong>= true or false 
}

Gradle Task와 생명주기

프로젝트 빌드하고 실행

 안드로이드 스튜디오에서 기본적으로 빌드하려면 build -> make project 하면 됩니다. 

안드로이드 스튜디오 오른쪽화면을 보면 Gradle Projects가 있습니다. Sync버튼을 누르면 Gradle 관점에서 모듈별 Task목록들을 보실 수 있습니다.
저는 GradeTest라는 프로젝트를 생성했었습니다. 생성할때 처음 기본 모듈이름은 app이라고 안드로이드 스튜디오에서 기본적으로 설정됩니다.
모듈은 콜론으로 구분합니다. 그래서 :app으로 되어있는것을 보실 수 있습니다. 
app 모듈의 build그룹을 열어서, assembleDebug를 더블클릭하여 실행해봅시다. 
앞으로는 프로젝트를 직접 빌드하기보다는 Module 단위로 빌드를 진행합니다. 멀티프로젝트에서는 각 Module이 빌드의 주체가 됩니다.
또한, Project이름 > app > build > outputs > apk 폴더에 빌드된 apk 파일을 확인하실 수 있습니다.

Gradle Task 개념

 Module의 개수가 늘어나면 안드로이드 Gradle 플러그인에서 제공하는 Task 외 새로운 Task 를 추가하여 활용하게 됩니다. Task의 개념을 알고있으면 유용하게 써먹을 수 있게 됩니다.

Task 에서 Log 출력

 sayHi라는 Task를 추가해보겠습니다. 또한 Hello MyGradle 문구를 출력하는 Task를 만들도록하겠습니다. 문자열 출력할때는  ”를 사용해도되고 “”를 사용해도됩니다. “” 같은경우는 내부변수를 문자열에서 참조할때 사용하기도 합니다.
안드로이드 스튜디오 프로젝트에서, app module(프로젝트 모듈말고!)의 build.gradle을 열고 task를 추가해봅시다.

task sayHi << {
   println 'hello MyGradle'
}

그리고 오른쪽 gradle project창에서 싱크버튼을 누릅니다. Other 그룹을 펼쳐보면 sayHi Task가 생성된것을 확인할 수 있습니다. 거기서 더블클릭하면 sayHi Task 가 실행됩니다. 아래와 같이 app:sayHi task가 실행되면서, hello MyGradle이 출력되는것을 확인할 수 있습니다.

참고로 위에서 작성한코드는 아래코드와 같은 코드입니다. doLast부분이 들어가는것을 주의깊게 보세요. 뒤에서 설명합니다.

task hello {
    doLast {
        println 'Hello world!'
    }
}

Task 그룹 지정

Gradle Task는 그룹을 가질 수 있습니다. 앞서만들었던 sayHi를 help 그룹에 편입시켜 보겠습니다.

task sayHi(group: "help")<< {
    println 'hello MyGradle'
}

sayHi는 help그룹에 어울리않으니 다른 그룹을 생성해서 지정해보겠습니다. insa 그룹을 생성하고 지정했습니다.

def insa = 'insa'
task sayHi(group: insa)<< {
    println 'hello MyGradle'
}

sayHi Task가 insa 그룹에 편입된것을 확인하실 수 있습니다.

Task 설명 지정하기

Task가 어떤일을 하는 지 설명을 해야할 필요가 있겠죠? description을 넣고 설명을 달았습니다.

def insa = 'insa'
task sayHi(group: insa, description: "my insa is hello")<< {
    println 'hello MyGradle'
}

Gradle 내장태스크 tasks를 실행하면 다음과 같이 설명글을 볼 수 있습니다.
insa 그룹의 tasks들이 출력되고, sayHi Task의 설명을 확인하실 수 있습니다.

Task에 dependency 지정하기

 어떤 task를 내 task 보다 먼저 실행되도록하려면 dependsOn 이라는 속성을 지정하면 됩니다.
meetPerson Task를 먼저 실행하고 sayHi Task를 진행하도록 하겠습니다.

def insa = 'insa'
task sayHi(group: insa, description: "my insa is hello",
    dependsOn: 'meetPerson')<< {
    println 'hello MyGradle'
}

task meetPerson(group: insa) << {
    println "I met person"
}

동기화를 해보고 sayHi를 실행해보겠습니다.

meetPerson Task 실행되고, sayHi Task가 실행되는것을 확인하실 수 있습니다.

Gradle 생명주기

Gradle 생명주기는 초기화 단계, 설정 단계, 실행 단계로 구분됩니다.

초기화단계

 프로젝트 build.gradle에 해당하는 Project Instance를 생성합니다. 앞서 봤던 buildscript, allprojects 블록등도 포함됩니다.

설정단계

 Project객체의 detail한 값을 설정합니다. 하위 프로젝트에 공통으로 적용되는 내용은 앞선 포스팅에서 설명했던 allprojects() 을 통해 전달됩니다. 사용자 정의 task의 경우 doFirst()에 넣은 내용이 이때 실행됩니다.

실행단계

설정 단계를 마치고, 각 Task는 이제 실행되기만 하면됩니다. 사용자 정의 Task 의 경우 doLast() 또는 <<에 넣은 내용이 이때 실행되게 됩니다. 
만약 sayHi task에서 doFirst()와 doLast()를 각각 새로 정의했다면 doFirst()에 넣은 내용은 설정단계에서, doLast()에 넣은 내용은 실행단계에서 실행됩니다.

def insa = 'insa'
task sayHi(group: insa)  {
  doFirst{
      println 'Look at me'
  }
    doLast{
        println 'hello MyGradle'
    }

}

:app:sayHi
Look at me
hello MyGradle
BUILD SUCCESSFUL

Look at me가 실행되고 Hello MyGradle이 출력되는것을 확인하실 수 있습니다.참고로 위의 코드에 dependsOn: ‘meetPerson’ 을 넣으면, meetPerson Task가 실행되고, Look at me 다음 hellot MyGradle이 실행됩니다. 

Android studio Gradle 프로젝트 생성

Hello World 안드로이드 스튜디오 프로젝트 생성

안드로이드 스튜디오를 실행합니다. 그리고 File -> new -> New Project를 누릅니다. 음 API24 정도로 선택합니다. Activity 는 Blank Activity로 선택하겠습니다. 저흰 액티비티가 중요한게 아니니깐요.

 그리고 프로젝트 표시는 Android View로 하도록 하겠습니다. 왜냐하면 안드로이드 앱의 Gradle 스크립트를 잘표현해주기 때문입니다.

Gradle 구성요소 소개

(1) app모듈 안드로이드 스튜디오는 멀티프로젝트를 생성하게 됩니다. Gradle 프로젝트 하위에는 적어도 1개 이상의 모듈을 포합합니다. 최초 생성되는 모듈 이름은 app입니다. 새로운 모듈을 추가할때는
File -> New -> New Module 
을 선택하면됩니다.
(2) manifest 폴더AndroidManifest.xml 파일을 표시합니다. 앱이름과 권한 과 같은 프로젝트 메타정보를 담고 있습니다. 모듈별로 AndroidManifest.xml 파일을 포함하게 됩니다.
(3) java 폴더소스코드와 테스트 코드가 있습니다.
(4) Gradle Scripts안드로이드 스튜디오에서의 gradle sciprt을 포함하고있습니다.

프로젝트 build.gradle (여기선 Project: HelloWorld)

// Top-level build file where you can add configuration options common to all sub-projects/modules.

buildscript {
    repositories {
        jcenter()
    }
    dependencies {
        classpath 'com.android.tools.build:gradle:2.3.3'

        // NOTE: Do not place your application dependencies here; they belong
        // in the individual module build.gradle files
    }
}

allprojects {
    repositories {
        jcenter()
    }
}

task clean(type: Delete) {
    delete rootProject.buildDir
} 

직접 까서 보겠습니다.프로젝트 build gradle은 다수의 모듈이 존재할떄 전체 모듈에 공통적으로 적용하는 부분을 기술하는 부분입니다.
크게 두부분으로 구성되어있습니다. buildscirpt은 빌드 스크립트를 구동하는 부분입니다. 외부저장소와 의존성 부분을 지정합니다. 외부저장소로 jcenter, mavenCentral 이 있습니다.요즘은 jcenter를 많이 사용한다고 합니다. 또한 dependencies(의존성부분) 에는 안드로이드 gradle의 플로그인 버전을 기술합니다.
그외에는 전체 프로젝트 공통으로 사용할수 있는 task 를 정의합니다. 기본적으로 clean 태스크가 추가되며, 단순히 build 폴저를 제거하는 역할을 하고있습니다. app과 같은 하위 Module의 build 폴더도 모두 제거한답니다. 모듈이 다수이면 다수모듈의 build 폴더가 제거되겠지요?

모듈 build.gradle (여기선 Module:app)

apply plugin: 'com.android.application'

android {
    compileSdkVersion 26
    buildToolsVersion "26.0.0"
    defaultConfig {
        applicationId "com.example.pjh.helloworld"
        minSdkVersion 24
        targetSdkVersion 26
        versionCode 1
        versionName "1.0"
        testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
    }
    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
        }
    }
}

dependencies {
    compile fileTree(dir: 'libs', include: ['*.jar'])
    androidTestCompile('com.android.support.test.espresso:espresso-core:2.2.2', {
        exclude group: 'com.android.support', module: 'support-annotations'
    })
    compile 'com.android.support:appcompat-v7:26.+'
    compile 'com.android.support.constraint:constraint-layout:1.0.2'
    testCompile 'junit:junit:4.12'
} 

크게 네 부분으로 구분됩니다. 
첫번째는 모듈의 plugin 부분입니다.안드로이드 App module 은 com.android.application을 지정합니다.안드로이드 library module은 com.android.library를 지정합니다.동시에 플러그인을 2개 지정할수는 당연히 없습니다.
두번째는 android로 AndroidManifest.xml을 재설정 한다고 생각하시면 됩니다.gradle에서 설정한게 manifest보다 우선시 됩니다. 
세번째는 buildTypes에는 빌드 타입에 따라 다른 동작을 지정할 수 있습니다.빌드타입에 따라 다른 동작을 지정할 수 있습니다. debug와 release가 있습니다. debug는 개발단계에서 사용되며, release는 마켓이나 외부에 배포할때 사용합니다. 
네번째는 의존성부분(dependencies) 입니다.libs폴더에있는 모든 jar파일 의존성에 추가합니다.또한 compile은 module을 빌드 할 때 포함하는 외부라이브러리입니다. 로컬에 존재하지 않는 경우, 앞서 지정한 저장소였던 jcenter(프로젝트 gradle에서) appcompat 지원 라이브러리를 다운로드합니다.

Gradle 개요

Gradle OverView

Gradle은 Gradle 사에서 만든 범용 빌드 도구중에 하나입니다. 안드로이드에서 빌드 뿐만 아니라, java, c/c++ 등의 모든 범용 언어를 지원합니다. 그러므로 엄청 강력한 툴이지요. 그래서 Gradle을 한번 배워두면 다른언어로 개발하더라도 빌드 스크립트를 처음부터 다시 작성할 필요없이 재사용하실 수 있습니다. 누차 강조하지만 꼭 gradle을 배워보도록 합시다.

다음은 gradle의 주요 특징 4가지 입니다.

1. Polyglot Build

Gradle은 각 언어를 플러그인으로 구별합니다. java는 java, java 웹 프로젝트는 war, 안드로이드 앱은 com.android.application 플로그인을 사용하면 됩니다.

2. 도구통합

gradle은 이클립스, 안드로이드 스튜디오 와 같은 IDE에서 정말 편리하게 사용할 수 있도록 창을 제공하고있습니다. 또한 젠킨스와 함께 활용할 수 도있습니다. 소스코드가 git에 업로드되면 서버에서 CheckStyle, FindBugs 등의 플러그인을 활용하여 소스코드가 잠재적으로 가진 문제를 검출하여 개발자에게 통보하거나 위험한 코 드를 merge할 수 없도록 강제적으로 할 수 있습니다.

3. 외부 라이브러리 관리 자동화

Gradle의 또다른 장점중 하나는 개발자가 더는 외부라이브러리를 관리하지 않아도 된다는 점입니다. 개인적으로 이점이 가장 마음에 듭니다. 이클립스에서 개발했던 과거의 경우에는 libs 폴더에 원하는 외부라이브러리 파일을 직접 복사하였지만, Gradle 에서는 단순히 외부 저장소 위치와 라이브러리의 그룹, 이름, 버전 등을 지정해주면 알아서 다운로드하고 빌드에 포함시키게 됩니다. 이얼마나 편하고 좋습니까.

4. 고성능 빌드

Gradle은 점진적 빌드, 빌드캐싱, 병렬 빌드 기능을 지원하는 고성능 빌드를 지향합니다. 하지만 실제로 우니도우 환경에서 안드로이드 앱을 빌드해보면 gradle 메모리 사용량이 많고 빌드시 CPU 점유율이 엄청 높게 잡힙니다. 실무에서 권장하는 사양은 메모리 8GB와 SSD 장착입니다. 메모리 4GB에서 모듈의 수가 늘어나면 Out of memery 현상이 발생합니다. 또한 heap이 부족하다고 난리치기도 한답니다.(진짜 경험)

안드로이드 스튜디오에서 Gradle의 특징을 살펴보겠습니다.

1. 멀티 프로젝트 구조

안드로이드 스튜디오에서 프로젝트를 생성하면 멀티 프로젝트로 생성됩니다. app이라는 폴더가 있는데 이를 Gradle에서는 모듈이라고 부릅니다. gradle에서는 app 모듈뿐만 아니라 새로운 모듈을 추가하여 모듈별로 src 폴더를 포함하게 됩니다.

2. src폴더 구조가 다름

androidTest, main, test 폴더를 확인하실 수 있습니다. Test 폴더는 Local Unit Test 를 지원합니다.

3. libs 폴더

이클립스 libs 폴더에는 빌드하는데 필요한 외부라이브러리를 직접 포함시켜야 했습니다. 하지만 gradle에서는 의존성 관리를 gradle이 담당하므로 libs 폴더를 사용하지 않아도됩니다. 필요한 스크립트 파일에서 외부라이브러리의 저장소와 버전등을 지정하면 빌드할 때 알아서 해당 버전을 다운로드하여 포함합니다. 또한, +옵션등을 적용하면 최신버전을 자동으로 다운로드 할 수 있습니다.

4. bin 폴더

gradle에서 빌드를하면 build/output/apk 폴더에 apk 파일이 위치하게됩니다. 혹은 AAR파일도 있게 됩니다.

In-app Billing API v3 throws IllegalStateException

If you have followed Google’s instructions when implementing version 3 of the Android’s in-app billing API by exploring their TrivialDrive sample app, you probably have come upon some numerous IllegalStateExceptions, like these:

java.lang.IllegalStateException: Can't start async operation (consume) because another async operation(consume) is in progress.
...
java.lang.IllegalStateException: IAB helper is not set up. Can't perform operation: queryInventory
...
java.lang.IllegalStateException: IabHelper was disposed of, so it cannot be used.

The reason for these exceptions being thrown lies in the IabHelper class of the TrivialDrive sample app. This class uses multiple threads, which manipulate on the same variables (mSetupDone, mDisposed, mAsyncInProgress, etc.) and there is no guarantee that a variable changed by one thread will be visible to the other due to CPU caching. To avoid this scenario, these member variables need to be declared volatile.

The dispose() function of this class also has an issue, where the call to unbindService could throw IllegalArgumentException if dispose() was called before the service was bound in the startSetup() method. Consider what happens when you initiate the setup, but the user clicks the back button and closes the activity? To handle this we need to catch that exception in the dispose() method.

The other issue with this class is the poor choice of throwing RuntimeExcptions (IllegalStateException) in multiple methods. Throwing RuntimeExeptions from your own code in most cases is not desirable due to the fact that they are unchecked exceptions. That is like sabotaging your own application- if not caught, these exceptions will crash your app.

The solution to this is to create your own checked exception and change the IabHelper class to throw it, instead of the IllegalStateException. That will force you to handle this exception everywhere it could be thrown in your code at compile time.

Here is my custom exception:

public class MyIllegalStateException extends Exception {
	
    private static final long serialVersionUID = 1L;
	
	//Parameterless Constructor
    public MyIllegalStateException() {}

    //Constructor that accepts a message
    public MyIllegalStateException(String message)
    {
       super(message);
    }
}

And now let’s make the changes in the IabHelper class. We will declare the necessary variables volatile, fix the dispose method, throw our own checked exception and in most cases we will let it bubble up from the methods of the IabHelper class, we can handle it in our code where we call these methods. For example, in your activity or fragment that handles the in-app purchases you do:

try {
   setUpBilling(targetActivityInstance.allData.getAll());
} catch (MyIllegalStateException ex) {
    ex.printStackTrace();
}

And here is the class in its entirety:

/* Copyright (c) 2012 Google Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

/**
 * Provides convenience methods for in-app billing. You can create one instance of this
 * class for your application and use it to process in-app billing operations.
 * It provides synchronous (blocking) and asynchronous (non-blocking) methods for
 * many common in-app billing operations, as well as automatic signature
 * verification.
 *
 * After instantiating, you must perform setup in order to start using the object.
 * To perform setup, call the {@link #startSetup} method and provide a listener;
 * that listener will be notified when setup is complete, after which (and not before)
 * you may call other methods.
 *
 * After setup is complete, you will typically want to request an inventory of owned
 * items and subscriptions. See {@link #queryInventory}, {@link #queryInventoryAsync}
 * and related methods.
 *
 * When you are done with this object, don't forget to call {@link #dispose}
 * to ensure proper cleanup. This object holds a binding to the in-app billing
 * service, which will leak unless you dispose of it correctly. If you created
 * the object on an Activity's onCreate method, then the recommended
 * place to dispose of it is the Activity's onDestroy method.
 *
 * A note about threading: When using this object from a background thread, you may
 * call the blocking versions of methods; when using from a UI thread, call
 * only the asynchronous versions and handle the results via callbacks.
 * Also, notice that you can only call one asynchronous operation at a time;
 * attempting to start a second asynchronous operation while the first one
 * has not yet completed will result in an exception being thrown.
 *
 * @author Bruno Oliveira (Google)
 *
 */
public class IabHelper {
    // Is debug logging enabled?
    boolean mDebugLog = false;
    String mDebugTag = "IabHelper";

    // Is setup done?
    volatile boolean mSetupDone = false;

    // Has this object been disposed of? (If so, we should ignore callbacks, etc)
    volatile boolean mDisposed = false;

    // Are subscriptions supported?
    volatile boolean mSubscriptionsSupported = false;

    // Is an asynchronous operation in progress?
    // (only one at a time can be in progress)
    volatile boolean mAsyncInProgress = false;

    // (for logging/debugging)
    // if mAsyncInProgress == true, what asynchronous operation is in progress?
    String mAsyncOperation = "";

    // Context we were passed during initialization
    Context mContext;

    // Connection to the service
    IInAppBillingService mService;
    ServiceConnection mServiceConn;

    // The request code used to launch purchase flow
    int mRequestCode;

    // The item type of the current purchase flow
    String mPurchasingItemType;

    // Public key for verifying signature, in base64 encoding
    String mSignatureBase64 = null;

    // Billing response codes
    public static final int BILLING_RESPONSE_RESULT_OK = 0;
    public static final int BILLING_RESPONSE_RESULT_USER_CANCELED = 1;
    public static final int BILLING_RESPONSE_RESULT_BILLING_UNAVAILABLE = 3;
    public static final int BILLING_RESPONSE_RESULT_ITEM_UNAVAILABLE = 4;
    public static final int BILLING_RESPONSE_RESULT_DEVELOPER_ERROR = 5;
    public static final int BILLING_RESPONSE_RESULT_ERROR = 6;
    public static final int BILLING_RESPONSE_RESULT_ITEM_ALREADY_OWNED = 7;
    public static final int BILLING_RESPONSE_RESULT_ITEM_NOT_OWNED = 8;

    // IAB Helper error codes
    public static final int IABHELPER_ERROR_BASE = -1000;
    public static final int IABHELPER_REMOTE_EXCEPTION = -1001;
    public static final int IABHELPER_BAD_RESPONSE = -1002;
    public static final int IABHELPER_VERIFICATION_FAILED = -1003;
    public static final int IABHELPER_SEND_INTENT_FAILED = -1004;
    public static final int IABHELPER_USER_CANCELLED = -1005;
    public static final int IABHELPER_UNKNOWN_PURCHASE_RESPONSE = -1006;
    public static final int IABHELPER_MISSING_TOKEN = -1007;
    public static final int IABHELPER_UNKNOWN_ERROR = -1008;
    public static final int IABHELPER_SUBSCRIPTIONS_NOT_AVAILABLE = -1009;
    public static final int IABHELPER_INVALID_CONSUMPTION = -1010;

    // Keys for the responses from InAppBillingService
    public static final String RESPONSE_CODE = "RESPONSE_CODE";
    public static final String RESPONSE_GET_SKU_DETAILS_LIST = "DETAILS_LIST";
    public static final String RESPONSE_BUY_INTENT = "BUY_INTENT";
    public static final String RESPONSE_INAPP_PURCHASE_DATA = "INAPP_PURCHASE_DATA";
    public static final String RESPONSE_INAPP_SIGNATURE = "INAPP_DATA_SIGNATURE";
    public static final String RESPONSE_INAPP_ITEM_LIST = "INAPP_PURCHASE_ITEM_LIST";
    public static final String RESPONSE_INAPP_PURCHASE_DATA_LIST = "INAPP_PURCHASE_DATA_LIST";
    public static final String RESPONSE_INAPP_SIGNATURE_LIST = "INAPP_DATA_SIGNATURE_LIST";
    public static final String INAPP_CONTINUATION_TOKEN = "INAPP_CONTINUATION_TOKEN";

    // Item types
    public static final String ITEM_TYPE_INAPP = "inapp";
    public static final String ITEM_TYPE_SUBS = "subs";

    // some fields on the getSkuDetails response bundle
    public static final String GET_SKU_DETAILS_ITEM_LIST = "ITEM_ID_LIST";
    public static final String GET_SKU_DETAILS_ITEM_TYPE_LIST = "ITEM_TYPE_LIST";

    /**
     * Creates an instance. After creation, it will not yet be ready to use. You must perform
     * setup by calling {@link #startSetup} and wait for setup to complete. This constructor does not
     * block and is safe to call from a UI thread.
     *
     * @param ctx Your application or Activity context. Needed to bind to the in-app billing service.
     * @param base64PublicKey Your application's public key, encoded in base64.
     *     This is used for verification of purchase signatures. You can find your app's base64-encoded
     *     public key in your application's page on Google Play Developer Console. Note that this
     *     is NOT your "developer public key".
     */
    public IabHelper(Context ctx, String base64PublicKey) {
        mContext = ctx.getApplicationContext();
        mSignatureBase64 = base64PublicKey;
        logDebug("IAB helper created.");
    }

    /**
     * Enables or disable debug logging through LogCat.
     */
    public void enableDebugLogging(boolean enable, String tag) throws MyIllegalStateException {
        checkNotDisposed();
        mDebugLog = enable;
        mDebugTag = tag;
    }

    public void enableDebugLogging(boolean enable) throws MyIllegalStateException {
        checkNotDisposed();
        mDebugLog = enable;
    }

    /**
     * Callback for setup process. This listener's {@link #onIabSetupFinished} method is called
     * when the setup process is complete.
     */
    public interface OnIabSetupFinishedListener {
        /**
         * Called to notify that setup is complete.
         *
         * @param result The result of the setup process.
         */
        public void onIabSetupFinished(IabResult result);
    }

    /**
     * Starts the setup process. This will start up the setup process asynchronously.
     * You will be notified through the listener when the setup process is complete.
     * This method is safe to call from a UI thread.
     *
     * @param listener The listener to notify when the setup process is complete.
     */
    public void startSetup(final OnIabSetupFinishedListener listener) throws MyIllegalStateException {
        // If already set up, can't do it again.
        checkNotDisposed();
        if (mSetupDone) throw new MyIllegalStateException("IAB helper is already set up.");

        // Connection to IAB service
        logDebug("Starting in-app billing setup.");
        mServiceConn = new ServiceConnection() {
            @Override
            public void onServiceDisconnected(ComponentName name) {
                logDebug("Billing service disconnected.");
                mService = null;
            }

            @Override
            public void onServiceConnected(ComponentName name, IBinder service) {
                if (mDisposed) return;
                logDebug("Billing service connected.");
                mService = IInAppBillingService.Stub.asInterface(service);
                String packageName = mContext.getPackageName();
                try {
                    logDebug("Checking for in-app billing 3 support.");

                    // check for in-app billing v3 support
                    int response = mService.isBillingSupported(3, packageName, ITEM_TYPE_INAPP);
                    if (response != BILLING_RESPONSE_RESULT_OK) {
                        if (listener != null) listener.onIabSetupFinished(new IabResult(response,
                                "Error checking for billing v3 support."));

                        // if in-app purchases aren't supported, neither are subscriptions.
                        mSubscriptionsSupported = false;
                        return;
                    }
                    logDebug("In-app billing version 3 supported for " + packageName);

                    // check for v3 subscriptions support
                    response = mService.isBillingSupported(3, packageName, ITEM_TYPE_SUBS);
                    if (response == BILLING_RESPONSE_RESULT_OK) {
                        logDebug("Subscriptions AVAILABLE.");
                        mSubscriptionsSupported = true;
                    }
                    else {
                        logDebug("Subscriptions NOT AVAILABLE. Response: " + response);
                    }

                    mSetupDone = true;
                }
                catch (RemoteException e) {
                    if (listener != null) {
                        listener.onIabSetupFinished(new IabResult(IABHELPER_REMOTE_EXCEPTION,
                                                    "RemoteException while setting up in-app billing."));
                    }
                    e.printStackTrace();
                    return;
                }

                if (listener != null) {
                    listener.onIabSetupFinished(new IabResult(BILLING_RESPONSE_RESULT_OK, "Setup successful."));
                }
            }
        };

        Intent serviceIntent = new Intent("com.android.vending.billing.InAppBillingService.BIND");
        serviceIntent.setPackage("com.android.vending");
        if (!mContext.getPackageManager().queryIntentServices(serviceIntent, 0).isEmpty()) {
            // service available to handle that Intent
            mContext.bindService(serviceIntent, mServiceConn, Context.BIND_AUTO_CREATE);
        }
        else {
            // no service available to handle that Intent
            if (listener != null) {
                listener.onIabSetupFinished(
                        new IabResult(BILLING_RESPONSE_RESULT_BILLING_UNAVAILABLE,
                        "Billing service unavailable on device."));
            }
        }
    }

    /**
     * Dispose of object, releasing resources. It's very important to call this
     * method when you are done with this object. It will release any resources
     * used by it such as service connections. Naturally, once the object is
     * disposed of, it can't be used again.
     */
    public void dispose() {
        logDebug("Disposing.");
        mSetupDone = false;
        if (mServiceConn != null) {
            logDebug("Unbinding from service.");
            try{
                if (mContext != null) mContext.unbindService(mServiceConn);
            } catch(IllegalArgumentException ex){ //ADDED THIS CATCH
                //somehow, the service was already unregistered
            	ex.printStackTrace();
            }
        }
        mDisposed = true;
        mContext = null;
        mServiceConn = null;
        mService = null;
        mPurchaseListener = null;
    }

    private void checkNotDisposed() throws MyIllegalStateException {
        if (mDisposed) throw new MyIllegalStateException("IabHelper was disposed of, so it cannot be used.");
    }

    /** Returns whether subscriptions are supported. */
    public boolean subscriptionsSupported() throws MyIllegalStateException {
        checkNotDisposed();
        return mSubscriptionsSupported;
    }


    /**
     * Callback that notifies when a purchase is finished.
     */
    public interface OnIabPurchaseFinishedListener {
        /**
         * Called to notify that an in-app purchase finished. If the purchase was successful,
         * then the sku parameter specifies which item was purchased. If the purchase failed,
         * the sku and extraData parameters may or may not be null, depending on how far the purchase
         * process went.
         *
         * @param result The result of the purchase.
         * @param info The purchase information (null if purchase failed)
         */
        public void onIabPurchaseFinished(IabResult result, Purchase info);
    }

    // The listener registered on launchPurchaseFlow, which we have to call back when
    // the purchase finishes
    OnIabPurchaseFinishedListener mPurchaseListener;

    public void launchPurchaseFlow(Activity act, String sku, int requestCode, OnIabPurchaseFinishedListener listener) throws MyIllegalStateException {
        launchPurchaseFlow(act, sku, requestCode, listener, "");
    }

    public void launchPurchaseFlow(Activity act, String sku, int requestCode,
            OnIabPurchaseFinishedListener listener, String extraData) throws MyIllegalStateException {
        launchPurchaseFlow(act, sku, ITEM_TYPE_INAPP, requestCode, listener, extraData);
    }

    public void launchSubscriptionPurchaseFlow(Activity act, String sku, int requestCode,
            OnIabPurchaseFinishedListener listener) throws MyIllegalStateException {
        launchSubscriptionPurchaseFlow(act, sku, requestCode, listener, "");
    }

    public void launchSubscriptionPurchaseFlow(Activity act, String sku, int requestCode,
            OnIabPurchaseFinishedListener listener, String extraData) throws MyIllegalStateException {
        launchPurchaseFlow(act, sku, ITEM_TYPE_SUBS, requestCode, listener, extraData);
    }

    /**
     * Initiate the UI flow for an in-app purchase. Call this method to initiate an in-app purchase,
     * which will involve bringing up the Google Play screen. The calling activity will be paused while
     * the user interacts with Google Play, and the result will be delivered via the activity's
     * {@link android.app.Activity#onActivityResult} method, at which point you must call
     * this object's {@link #handleActivityResult} method to continue the purchase flow. This method
     * MUST be called from the UI thread of the Activity.
     *
     * @param act The calling activity.
     * @param sku The sku of the item to purchase.
     * @param itemType indicates if it's a product or a subscription (ITEM_TYPE_INAPP or ITEM_TYPE_SUBS)
     * @param requestCode A request code (to differentiate from other responses --
     *     as in {@link android.app.Activity#startActivityForResult}).
     * @param listener The listener to notify when the purchase process finishes
     * @param extraData Extra data (developer payload), which will be returned with the purchase data
     *     when the purchase completes. This extra data will be permanently bound to that purchase
     *     and will always be returned when the purchase is queried.
     */
    public void launchPurchaseFlow(Activity act, String sku, String itemType, int requestCode,
                        OnIabPurchaseFinishedListener listener, String extraData) throws MyIllegalStateException {
        checkNotDisposed();
        checkSetupDone("launchPurchaseFlow");
        flagStartAsync("launchPurchaseFlow");
        IabResult result;

        if (itemType.equals(ITEM_TYPE_SUBS) && !mSubscriptionsSupported) {
            IabResult r = new IabResult(IABHELPER_SUBSCRIPTIONS_NOT_AVAILABLE,
                    "Subscriptions are not available.");
            flagEndAsync();
            if (listener != null) listener.onIabPurchaseFinished(r, null);
            return;
        }

        try {
            logDebug("Constructing buy intent for " + sku + ", item type: " + itemType);
            Bundle buyIntentBundle = mService.getBuyIntent(3, mContext.getPackageName(), sku, itemType, extraData);
            int response = getResponseCodeFromBundle(buyIntentBundle);
            if (response != BILLING_RESPONSE_RESULT_OK) {
                logError("Unable to buy item, Error response: " + getResponseDesc(response));
                flagEndAsync();
                result = new IabResult(response, "Unable to buy item");
                if (listener != null) listener.onIabPurchaseFinished(result, null);
                return;
            }

            PendingIntent pendingIntent = buyIntentBundle.getParcelable(RESPONSE_BUY_INTENT);
            logDebug("Launching buy intent for " + sku + ". Request code: " + requestCode);
            mRequestCode = requestCode;
            mPurchaseListener = listener;
            mPurchasingItemType = itemType;
            act.startIntentSenderForResult(pendingIntent.getIntentSender(),
                                           requestCode, new Intent(),
                                           Integer.valueOf(0), Integer.valueOf(0),
                                           Integer.valueOf(0));
        }
        catch (SendIntentException e) {
            logError("SendIntentException while launching purchase flow for sku " + sku);
            e.printStackTrace();
            flagEndAsync();

            result = new IabResult(IABHELPER_SEND_INTENT_FAILED, "Failed to send intent.");
            if (listener != null) listener.onIabPurchaseFinished(result, null);
        }
        catch (RemoteException e) {
            logError("RemoteException while launching purchase flow for sku " + sku);
            e.printStackTrace();
            flagEndAsync();

            result = new IabResult(IABHELPER_REMOTE_EXCEPTION, "Remote exception while starting purchase flow");
            if (listener != null) listener.onIabPurchaseFinished(result, null);
        }
    }

    /**
     * Handles an activity result that's part of the purchase flow in in-app billing. If you
     * are calling {@link #launchPurchaseFlow}, then you must call this method from your
     * Activity's {@link android.app.Activity@onActivityResult} method. This method
     * MUST be called from the UI thread of the Activity.
     *
     * @param requestCode The requestCode as you received it.
     * @param resultCode The resultCode as you received it.
     * @param data The data (Intent) as you received it.
     * @return Returns true if the result was related to a purchase flow and was handled;
     *     false if the result was not related to a purchase, in which case you should
     *     handle it normally.
     */
    public boolean handleActivityResult(int requestCode, int resultCode, Intent data) throws MyIllegalStateException {
        IabResult result;
        if (requestCode != mRequestCode) return false;

        checkNotDisposed();
        checkSetupDone("handleActivityResult");

        // end of async purchase operation that started on launchPurchaseFlow
        flagEndAsync();

        if (data == null) {
            logError("Null data in IAB activity result.");
            result = new IabResult(IABHELPER_BAD_RESPONSE, "Null data in IAB result");
            if (mPurchaseListener != null) mPurchaseListener.onIabPurchaseFinished(result, null);
            return true;
        }

        int responseCode = getResponseCodeFromIntent(data);
        String purchaseData = data.getStringExtra(RESPONSE_INAPP_PURCHASE_DATA);
        String dataSignature = data.getStringExtra(RESPONSE_INAPP_SIGNATURE);

        if (resultCode == Activity.RESULT_OK && responseCode == BILLING_RESPONSE_RESULT_OK) {
            logDebug("Successful resultcode from purchase activity.");
            logDebug("Purchase data: " + purchaseData);
            logDebug("Data signature: " + dataSignature);
            logDebug("Extras: " + data.getExtras());
            logDebug("Expected item type: " + mPurchasingItemType);

            if (purchaseData == null || dataSignature == null) {
                logError("BUG: either purchaseData or dataSignature is null.");
                logDebug("Extras: " + data.getExtras().toString());
                result = new IabResult(IABHELPER_UNKNOWN_ERROR, "IAB returned null purchaseData or dataSignature");
                if (mPurchaseListener != null) mPurchaseListener.onIabPurchaseFinished(result, null);
                return true;
            }

            Purchase purchase = null;
            try {
                purchase = new Purchase(mPurchasingItemType, purchaseData, dataSignature);
                String sku = purchase.getSku();

                // Verify signature
                if (!Security.verifyPurchase(mSignatureBase64, purchaseData, dataSignature)) {
                    logError("Purchase signature verification FAILED for sku " + sku);
                    result = new IabResult(IABHELPER_VERIFICATION_FAILED, "Signature verification failed for sku " + sku);
                    if (mPurchaseListener != null) mPurchaseListener.onIabPurchaseFinished(result, purchase);
                    return true;
                }
                logDebug("Purchase signature successfully verified.");
            }
            catch (JSONException e) {
                logError("Failed to parse purchase data.");
                e.printStackTrace();
                result = new IabResult(IABHELPER_BAD_RESPONSE, "Failed to parse purchase data.");
                if (mPurchaseListener != null) mPurchaseListener.onIabPurchaseFinished(result, null);
                return true;
            }

            if (mPurchaseListener != null) {
                mPurchaseListener.onIabPurchaseFinished(new IabResult(BILLING_RESPONSE_RESULT_OK, "Success"), purchase);
            }
        }
        else if (resultCode == Activity.RESULT_OK) {
            // result code was OK, but in-app billing response was not OK.
            logDebug("Result code was OK but in-app billing response was not OK: " + getResponseDesc(responseCode));
            if (mPurchaseListener != null) {
                result = new IabResult(responseCode, "Problem purchashing item.");
                mPurchaseListener.onIabPurchaseFinished(result, null);
            }
        }
        else if (resultCode == Activity.RESULT_CANCELED) {
            logDebug("Purchase canceled - Response: " + getResponseDesc(responseCode));
            result = new IabResult(IABHELPER_USER_CANCELLED, "User canceled.");
            if (mPurchaseListener != null) mPurchaseListener.onIabPurchaseFinished(result, null);
        }
        else {
            logError("Purchase failed. Result code: " + Integer.toString(resultCode)
                    + ". Response: " + getResponseDesc(responseCode));
            result = new IabResult(IABHELPER_UNKNOWN_PURCHASE_RESPONSE, "Unknown purchase response.");
            if (mPurchaseListener != null) mPurchaseListener.onIabPurchaseFinished(result, null);
        }
        return true;
    }

    public Inventory queryInventory(boolean querySkuDetails, List moreSkus) throws IabException, MyIllegalStateException {
        return queryInventory(querySkuDetails, moreSkus, null);
    }

    /**
     * Queries the inventory. This will query all owned items from the server, as well as
     * information on additional skus, if specified. This method may block or take long to execute.
     * Do not call from a UI thread. For that, use the non-blocking version {@link #refreshInventoryAsync}.
     *
     * @param querySkuDetails if true, SKU details (price, description, etc) will be queried as well
     *     as purchase information.
     * @param moreItemSkus additional PRODUCT skus to query information on, regardless of ownership.
     *     Ignored if null or if querySkuDetails is false.
     * @param moreSubsSkus additional SUBSCRIPTIONS skus to query information on, regardless of ownership.
     *     Ignored if null or if querySkuDetails is false.
     * @throws IabException if a problem occurs while refreshing the inventory.
     */
    public Inventory queryInventory(boolean querySkuDetails, List moreItemSkus,
                                        List moreSubsSkus) throws IabException, MyIllegalStateException {
        checkNotDisposed();
        checkSetupDone("queryInventory");
        try {
            Inventory inv = new Inventory();
            int r = queryPurchases(inv, ITEM_TYPE_INAPP);
            if (r != BILLING_RESPONSE_RESULT_OK) {
                throw new IabException(r, "Error refreshing inventory (querying owned items).");
            }

            if (querySkuDetails) {
                r = querySkuDetails(ITEM_TYPE_INAPP, inv, moreItemSkus);
                if (r != BILLING_RESPONSE_RESULT_OK) {
                    throw new IabException(r, "Error refreshing inventory (querying prices of items).");
                }
            }

            // if subscriptions are supported, then also query for subscriptions
            if (mSubscriptionsSupported) {
                r = queryPurchases(inv, ITEM_TYPE_SUBS);
                if (r != BILLING_RESPONSE_RESULT_OK) {
                    throw new IabException(r, "Error refreshing inventory (querying owned subscriptions).");
                }

                if (querySkuDetails) {
                    r = querySkuDetails(ITEM_TYPE_SUBS, inv, moreItemSkus);
                    if (r != BILLING_RESPONSE_RESULT_OK) {
                        throw new IabException(r, "Error refreshing inventory (querying prices of subscriptions).");
                    }
                }
            }

            return inv;
        }
        catch (RemoteException e) {
            throw new IabException(IABHELPER_REMOTE_EXCEPTION, "Remote exception while refreshing inventory.", e);
        }
        catch (JSONException e) {
            throw new IabException(IABHELPER_BAD_RESPONSE, "Error parsing JSON response while refreshing inventory.", e);
        }
    }

    /**
     * Listener that notifies when an inventory query operation completes.
     */
    public interface QueryInventoryFinishedListener {
        /**
         * Called to notify that an inventory query operation completed.
         *
         * @param result The result of the operation.
         * @param inv The inventory.
         */
        public void onQueryInventoryFinished(IabResult result, Inventory inv);
    }


    /**
     * Asynchronous wrapper for inventory query. This will perform an inventory
     * query as described in {@link #queryInventory}, but will do so asynchronously
     * and call back the specified listener upon completion. This method is safe to
     * call from a UI thread.
     *
     * @param querySkuDetails as in {@link #queryInventory}
     * @param moreSkus as in {@link #queryInventory}
     * @param listener The listener to notify when the refresh operation completes.
     */
    public void queryInventoryAsync(final boolean querySkuDetails,
                               final List moreSkus,
                               final QueryInventoryFinishedListener listener)  throws MyIllegalStateException {
        final Handler handler = new Handler();
        checkNotDisposed();
        checkSetupDone("queryInventory");
        flagStartAsync("refresh inventory");
        (new Thread(new Runnable() {
            public void run() {
                IabResult result = new IabResult(BILLING_RESPONSE_RESULT_OK, "Inventory refresh successful.");
                Inventory inv = null;
                try {
                    inv = queryInventory(querySkuDetails, moreSkus);
                }
                catch (IabException ex) {
                    result = ex.getResult();
                } catch(MyIllegalStateException ex){ 
				    result = new IabResult(BILLING_RESPONSE_RESULT_ERROR, ex.getMessage());
				    ex.printStackTrace();
				}

                flagEndAsync();

                final IabResult result_f = result;
                final Inventory inv_f = inv;
                if (!mDisposed && listener != null) {
                    handler.post(new Runnable() {
                        public void run() {
                            listener.onQueryInventoryFinished(result_f, inv_f);
                        }
                    });
                }
            }
        })).start();
    }

    public void queryInventoryAsync(QueryInventoryFinishedListener listener) throws MyIllegalStateException {
        queryInventoryAsync(true, null, listener);
    }

    public void queryInventoryAsync(boolean querySkuDetails, QueryInventoryFinishedListener listener) throws MyIllegalStateException {
        queryInventoryAsync(querySkuDetails, null, listener);
    }


    /**
     * Consumes a given in-app product. Consuming can only be done on an item
     * that's owned, and as a result of consumption, the user will no longer own it.
     * This method may block or take long to return. Do not call from the UI thread.
     * For that, see {@link #consumeAsync}.
     *
     * @param itemInfo The PurchaseInfo that represents the item to consume.
     * @throws IabException if there is a problem during consumption.
     */
    void consume(Purchase itemInfo) throws IabException {
        checkNotDisposed();
        checkSetupDone("consume");

        if (!itemInfo.mItemType.equals(ITEM_TYPE_INAPP)) {
            throw new IabException(IABHELPER_INVALID_CONSUMPTION,
                    "Items of type '" + itemInfo.mItemType + "' can't be consumed.");
        }

        try {
            String token = itemInfo.getToken();
            String sku = itemInfo.getSku();
            if (token == null || token.equals("")) {
               logError("Can't consume "+ sku + ". No token.");
               throw new IabException(IABHELPER_MISSING_TOKEN, "PurchaseInfo is missing token for sku: "
                   + sku + " " + itemInfo);
            }

            logDebug("Consuming sku: " + sku + ", token: " + token);
            int response = mService.consumePurchase(3, mContext.getPackageName(), token);
            if (response == BILLING_RESPONSE_RESULT_OK) {
               logDebug("Successfully consumed sku: " + sku);
            }
            else {
               logDebug("Error consuming consuming sku " + sku + ". " + getResponseDesc(response));
               throw new IabException(response, "Error consuming sku " + sku);
            }
        }
        catch (RemoteException e) {
            throw new IabException(IABHELPER_REMOTE_EXCEPTION, "Remote exception while consuming. PurchaseInfo: " + itemInfo, e);
        }
    }

    /**
     * Callback that notifies when a consumption operation finishes.
     */
    public interface OnConsumeFinishedListener {
        /**
         * Called to notify that a consumption has finished.
         *
         * @param purchase The purchase that was (or was to be) consumed.
         * @param result The result of the consumption operation.
         */
        public void onConsumeFinished(Purchase purchase, IabResult result);
    }

    /**
     * Callback that notifies when a multi-item consumption operation finishes.
     */
    public interface OnConsumeMultiFinishedListener {
        /**
         * Called to notify that a consumption of multiple items has finished.
         *
         * @param purchases The purchases that were (or were to be) consumed.
         * @param results The results of each consumption operation, corresponding to each
         *     sku.
         */
        public void onConsumeMultiFinished(List purchases, List results);
    }

    /**
     * Asynchronous wrapper to item consumption. Works like {@link #consume}, but
     * performs the consumption in the background and notifies completion through
     * the provided listener. This method is safe to call from a UI thread.
     *
     * @param purchase The purchase to be consumed.
     * @param listener The listener to notify when the consumption operation finishes.
     */
    public void consumeAsync(Purchase purchase, OnConsumeFinishedListener listener) throws MyIllegalStateException {
        checkNotDisposed();
        checkSetupDone("consume");
        List purchases = new ArrayList();
        purchases.add(purchase);
        consumeAsyncInternal(purchases, listener, null);
    }

    /**
     * Same as {@link consumeAsync}, but for multiple items at once.
     * @param purchases The list of PurchaseInfo objects representing the purchases to consume.
     * @param listener The listener to notify when the consumption operation finishes.
     */
    public void consumeAsync(List purchases, OnConsumeMultiFinishedListener listener) throws MyIllegalStateException {
        checkNotDisposed();
        checkSetupDone("consume");
        consumeAsyncInternal(purchases, null, listener);
    }

    /**
     * Returns a human-readable description for the given response code.
     *
     * @param code The response code
     * @return A human-readable string explaining the result code.
     *     It also includes the result code numerically.
     */
    public static String getResponseDesc(int code) {
        String[] iab_msgs = ("0:OK/1:User Canceled/2:Unknown/" +
                "3:Billing Unavailable/4:Item unavailable/" +
                "5:Developer Error/6:Error/7:Item Already Owned/" +
                "8:Item not owned").split("/");
        String[] iabhelper_msgs = ("0:OK/-1001:Remote exception during initialization/" +
                                   "-1002:Bad response received/" +
                                   "-1003:Purchase signature verification failed/" +
                                   "-1004:Send intent failed/" +
                                   "-1005:User cancelled/" +
                                   "-1006:Unknown purchase response/" +
                                   "-1007:Missing token/" +
                                   "-1008:Unknown error/" +
                                   "-1009:Subscriptions not available/" +
                                   "-1010:Invalid consumption attempt").split("/");

        if (code <= IABHELPER_ERROR_BASE) {
            int index = IABHELPER_ERROR_BASE - code;
            if (index >= 0 && index < iabhelper_msgs.length) return iabhelper_msgs[index];
            else return String.valueOf(code) + ":Unknown IAB Helper Error";
        }
        else if (code < 0 || code >= iab_msgs.length)
            return String.valueOf(code) + ":Unknown";
        else
            return iab_msgs[code];
    }


    // Checks that setup was done; if not, throws an exception.
    void checkSetupDone(String operation) throws MyIllegalStateException {
        if (!mSetupDone) {
            logError("Illegal state for operation (" + operation + "): IAB helper is not set up.");
            throw new MyIllegalStateException("IAB helper is not set up. Can't perform operation: " + operation);
        }
    }

    // Workaround to bug where sometimes response codes come as Long instead of Integer
    int getResponseCodeFromBundle(Bundle b) {
        Object o = b.get(RESPONSE_CODE);
        if (o == null) {
            logDebug("Bundle with null response code, assuming OK (known issue)");
            return BILLING_RESPONSE_RESULT_OK;
        }
        else if (o instanceof Integer) return ((Integer)o).intValue();
        else if (o instanceof Long) return (int)((Long)o).longValue();
        else {
            logError("Unexpected type for bundle response code.");
            logError(o.getClass().getName());
            throw new RuntimeException("Unexpected type for bundle response code: " + o.getClass().getName());
        }
    }

    // Workaround to bug where sometimes response codes come as Long instead of Integer
    int getResponseCodeFromIntent(Intent i) {
        Object o = i.getExtras().get(RESPONSE_CODE);
        if (o == null) {
            logError("Intent with no response code, assuming OK (known issue)");
            return BILLING_RESPONSE_RESULT_OK;
        }
        else if (o instanceof Integer) return ((Integer)o).intValue();
        else if (o instanceof Long) return (int)((Long)o).longValue();
        else {
            logError("Unexpected type for intent response code.");
            logError(o.getClass().getName());
            throw new RuntimeException("Unexpected type for intent response code: " + o.getClass().getName());
        }
    }

    void flagStartAsync(String operation) throws MyIllegalStateException {
        if (mAsyncInProgress) throw new MyIllegalStateException("Can't start async operation (" +
                operation + ") because another async operation(" + mAsyncOperation + ") is in progress.");
        mAsyncOperation = operation;
        mAsyncInProgress = true;
        logDebug("Starting async operation: " + operation);
    }

    void flagEndAsync() {
        logDebug("Ending async operation: " + mAsyncOperation);
        mAsyncOperation = "";
        mAsyncInProgress = false;
    }


    int queryPurchases(Inventory inv, String itemType) throws JSONException, RemoteException {
        // Query purchases
        logDebug("Querying owned items, item type: " + itemType);
        logDebug("Package name: " + mContext.getPackageName());
        boolean verificationFailed = false;
        String continueToken = null;

        do {
            logDebug("Calling getPurchases with continuation token: " + continueToken);
            Bundle ownedItems = mService.getPurchases(3, mContext.getPackageName(),
                    itemType, continueToken);

            int response = getResponseCodeFromBundle(ownedItems);
            logDebug("Owned items response: " + String.valueOf(response));
            if (response != BILLING_RESPONSE_RESULT_OK) {
                logDebug("getPurchases() failed: " + getResponseDesc(response));
                return response;
            }
            if (!ownedItems.containsKey(RESPONSE_INAPP_ITEM_LIST)
                    || !ownedItems.containsKey(RESPONSE_INAPP_PURCHASE_DATA_LIST)
                    || !ownedItems.containsKey(RESPONSE_INAPP_SIGNATURE_LIST)) {
                logError("Bundle returned from getPurchases() doesn't contain required fields.");
                return IABHELPER_BAD_RESPONSE;
            }

            ArrayList ownedSkus = ownedItems.getStringArrayList(
                        RESPONSE_INAPP_ITEM_LIST);
            ArrayList purchaseDataList = ownedItems.getStringArrayList(
                        RESPONSE_INAPP_PURCHASE_DATA_LIST);
            ArrayList signatureList = ownedItems.getStringArrayList(
                        RESPONSE_INAPP_SIGNATURE_LIST);

            for (int i = 0; i < purchaseDataList.size(); ++i) {
                String purchaseData = purchaseDataList.get(i);
                String signature = signatureList.get(i);
                String sku = ownedSkus.get(i);
                if (Security.verifyPurchase(mSignatureBase64, purchaseData, signature)) {
                    logDebug("Sku is owned: " + sku);
                    Purchase purchase = new Purchase(itemType, purchaseData, signature);

                    if (TextUtils.isEmpty(purchase.getToken())) {
                        logWarn("BUG: empty/null token!");
                        logDebug("Purchase data: " + purchaseData);
                    }

                    // Record ownership and token
                    inv.addPurchase(purchase);
                }
                else {
                    logWarn("Purchase signature verification **FAILED**. Not adding item.");
                    logDebug("   Purchase data: " + purchaseData);
                    logDebug("   Signature: " + signature);
                    verificationFailed = true;
                }
            }

            continueToken = ownedItems.getString(INAPP_CONTINUATION_TOKEN);
            logDebug("Continuation token: " + continueToken);
        } while (!TextUtils.isEmpty(continueToken));

        return verificationFailed ? IABHELPER_VERIFICATION_FAILED : BILLING_RESPONSE_RESULT_OK;
    }

    int querySkuDetails(String itemType, Inventory inv, List moreSkus)
                                throws RemoteException, JSONException {
        logDebug("Querying SKU details.");
        ArrayList skuList = new ArrayList();
        skuList.addAll(inv.getAllOwnedSkus(itemType));
        if (moreSkus != null) {
            for (String sku : moreSkus) {
                if (!skuList.contains(sku)) {
                    skuList.add(sku);
                }
            }
        }

        if (skuList.size() == 0) {
            logDebug("queryPrices: nothing to do because there are no SKUs.");
            return BILLING_RESPONSE_RESULT_OK;
        }

        Bundle querySkus = new Bundle();
        querySkus.putStringArrayList(GET_SKU_DETAILS_ITEM_LIST, skuList);
        Bundle skuDetails = mService.getSkuDetails(3, mContext.getPackageName(),
                itemType, querySkus);

        if (!skuDetails.containsKey(RESPONSE_GET_SKU_DETAILS_LIST)) {
            int response = getResponseCodeFromBundle(skuDetails);
            if (response != BILLING_RESPONSE_RESULT_OK) {
                logDebug("getSkuDetails() failed: " + getResponseDesc(response));
                return response;
            }
            else {
                logError("getSkuDetails() returned a bundle with neither an error nor a detail list.");
                return IABHELPER_BAD_RESPONSE;
            }
        }

        ArrayList responseList = skuDetails.getStringArrayList(
                RESPONSE_GET_SKU_DETAILS_LIST);

        for (String thisResponse : responseList) {
            SkuDetails d = new SkuDetails(itemType, thisResponse);
            logDebug("Got sku details: " + d);
            inv.addSkuDetails(d);
        }
        return BILLING_RESPONSE_RESULT_OK;
    }


    void consumeAsyncInternal(final List purchases,
                              final OnConsumeFinishedListener singleListener,
                              final OnConsumeMultiFinishedListener multiListener) throws MyIllegalStateException {
        final Handler handler = new Handler();
        flagStartAsync("consume");
        (new Thread(new Runnable() {
            public void run() {
                final List results = new ArrayList();
                for (Purchase purchase : purchases) {
                    try {
                        consume(purchase);
                        results.add(new IabResult(BILLING_RESPONSE_RESULT_OK, "Successful consume of sku " + purchase.getSku()));
                    }
                    catch (IabException ex) {
                        results.add(ex.getResult());
                    }
                }

                flagEndAsync();
                if (!mDisposed && singleListener != null) {
                    handler.post(new Runnable() {
                        public void run() {
                            singleListener.onConsumeFinished(purchases.get(0), results.get(0));
                        }
                    });
                }
                if (!mDisposed && multiListener != null) {
                    handler.post(new Runnable() {
                        public void run() {
                            multiListener.onConsumeMultiFinished(purchases, results);
                        }
                    });
                }
            }
        })).start();
    }

    void logDebug(String msg) {
        if (mDebugLog) Log.d(mDebugTag, msg);
    }

    void logError(String msg) {
        Log.e(mDebugTag, "In-app billing error: " + msg);
    }

    void logWarn(String msg) {
        Log.w(mDebugTag, "In-app billing warning: " + msg);
    }
}

코루틴 결과값 리턴받기

코루틴을 사용한 리소스 비동기 로딩 구현 도중 특정 코루틴의 결과값을 리턴받아야 할 일이 생겼습니다.

public Coroutine LoadCharacterAsync(CharacterData data)
{
    return StartCoroutine(LoadCharacterAsyncRoutine(data));
}
  
IEnumerator LoadCharacterAsyncRoutine(CharacterData data)
{
    GameObject characterObject = new GameObject();
    PokoCharacter character = characterObject.AddComponent<PokoCharacter>();
     
    // 캐릭터를 구성하는 각 파츠의 SkinnedMesh를 읽어서 생성
    for(int i = 0; i < 3; ++i)
    {
        GameObject loadedParts = null;
  
        yield return StartCoroutine(LoadCharacterPartsRoutine(data.partsName[i]);
  
        // 위 코루틴의 결과값을 어떻게 받지?!
        character.AddParts(i, loadedParts);
    }
}
  
IEnumerator LoadCharacterPartsRoutine(string partsName)
{
    // Mesh, Texture, Bone, Animation 등등 로딩
}

혹시나 이런 게 되나 싶었는데

IEnumerator LoadCharacterPartsRoutine(string partsName)
{
    // Mesh, Texture, Bone, Animation 등등 로딩
    return loadedParts;
}

될 리가 없죠;; 

IEnumerator로 선언된 메소드 내에서는 ref, out 같은 키워드도 문법적으로 사용할 수 없습니다. 

해서 구글링을 해 봤더니 C#의 람다를 콜백으로 사용하는 신박한 방법이 있더라구요. 

IEnumerator LoadCharacterPartsRoutine(string partsName)
{
    // Mesh, Texture, Bone, Animation 등등 로딩
    return loadedParts;
}