매년 우리의 Buffer iOS 앱은 애플의 World Wide Developer Conference(WWDC) 이후에 새로운 iOS 기능과 변화를 지원하기 위해 크게 업데이트 해왔었다.

올해에 큰 변화는 없었지만, 이번 포스팅에서는 퍼포먼스와 미래에대한 대비에대해 이야기 할 것이다.

새로운 버전의 Buffer 앱의 새로운 변화
WWDC가 끝나고나면 우리는 보통 앱을 실행해보고 이전 iOS 릴리즈 버전을 최소 타켓으로 조정한다. 이번의 경우 iOS9를 최소로 조정할 예정이었으나 iOS8 기기까지 지원하게 되었다. 그리하여 앱을 정리하고 이전 호환성에 더이상 필요없는 오래된 iOS8 코드를 제거할 수 있었으며, 몇년간 기술적인 빚을 갚을 수 있었다.

Buffer iOS의 6.0버전에서 증진시키고 싶은 부분중 하나는 바로 테이블이다. 특히 나중에는 레이아웃 부분을 더 유연하게 하고 싶었다. 우리는 2011년부터 UITableView를 사용하고 있었다. 이것은 컨텐트의 목록에는 좋지만, 나중에 v6에는 컨텐트를 그리드(grid)형식으로 보여주고 싶었다.

목표 : 부드러운 스크롤링을 위해 초당 60프레임을 지원
우리는 항상 부드러운 스크롤을 위해 마법처럼 초당 60프레임을 달성하려고 노력해가며 테이블을 업데이트 했다. 몇년간 아이폰이 더욱 강력해졌음에도 이것은 매우 어려운 목표치였고, 스마트폰이 싱글 코어 디바이스일때 설계된것같은 UIKit은 아직또 메인스레드에 묶여있다.

사용자가 프레임이 떨어지는 것을 알아차리지 않으려면 모든 레이아웃과 코드 랜더링이 16ms 안에 실행되야하는데, UIKit은 이것을 개발자에게 떠넘겼다. 시스템이 오버헤드하면 가끔 10ms 정도 휘청거린다.

우리의 UI는 매우 튼튼하고 다채로우므로 이 과제는 매년 포퍼먼스 유지보수를하게 만들었다. 이 기술적인 과제는 사용자가 "로딩" 화면을 보지 않으면서 스크롤 하는동안 어떻게 부드럽게 업데이트를 서버에서 검색하는지와 연관이 있다.

우리의 셀(cell)들은 사용자가 만든 소셜미디어 업데이트의 다양한 높이의 UI를 지원하는데, 이것이 꽤 복잡하다. 그래서 올해 애플이 UICollectionView의 퍼포먼스를 올렸다는 발표를 들었을 때 우리는 매우 흥미로웠다. 테이블 스크롤링을 하면서 처리할게 많을때 우리의 기대치는 60프레임을 유지하는 것이었고, 적어도 55프레임 이상 유지하길 바랬다.

우리는 먼저 앱에서 UICollectionView를 어떻게 이용할지부터 찾아보았지만, 현재의 UITableViews와 비슷한 레이아웃을 만들어내는데 몇 가지 장애물이 있었다. 컬랙션 셀의 오토사이징에대한 새로운 추가 사항과 함께 몇 가지 문제가있었다. 우리는 iOS가 높이를 계산하고나면 스크롤이 점프해서 왔다갔다하는 모습을 보고 있었다. (v5.0에서 가능하면 미리 높이를 계산해두는 방법을 찾았었다) 더 나아가 오토 레이아웃의 편리함은 무거운 처리의 비용으로 돌아오고, 이것은 메인스레드에서 일어났다.

전략: 페이스북의 AsyncDisplayKit을 사용하기
얼마전에 아직 보진 못했지만 페이스북이 AsyncDisplayKit(ASDK)를 만들었다는 소식을 접했다. 페이스북은 그들의 제스처와 애니메이션 특징의 앱(Paper)를 위해 2014년 10월부터 ASDK를 커스텀하여 만들고 있었다. 그들은 UIView의  스레드-세이프 추상화를 통해 메인스레드에서 뷰를 랜더링하는데, 초당 60프레임이 유지되도록하여 앱이 부드럽게 동작하게 만들었다. 추가로 ASDK는 "Intelligent Preloading"이라 불리는 강력한 API를 가지는데, 이것은 서버로부터 응답이 필요한 컨텐트 "목록"을 사용자가 스크롤하는 곳에 네트워크 호출과 불러오기를 효율적인 방법으로 다룬다.


우리는 이번 기회에 이것을 확인하여 사용해보자고 마음을 먹었고, 몇 시간 안에 텍스트만 갱신하면서 스크롤이 점프해서 왔다갔다 하지 않는 v6.0을 만들었다.

AsyncDisplayKit이 셀 랜더링을 처리해줌으로서 heightForRowAtIndexPath:나 estimatedHeightForRowAtIndexPath: 구현 없이 정확한 높이 값을 받아냈다. 이 컨셉 증명을 통해 아이폰5C에서 부드러운 60프레임을 확인할 수 있었다(아이폰6+에서는 이미 더 좋은 퍼포먼스를 냈다)

AsyncDisplayKit은 UI를 만들기위해 "Nodes"를 사용하는데, 우리의 바뀐 셀이 ASCellNode가 되도록 다시 만들어야했다. 또한 다른 요소를 새로운 클래스로 추상화하여 셀의 구조를 정리하는데 시간을 썼으며, 2800줄이나 되던것을 945줄까지 줄이게 되었다. ASCellNode는 또한 컬랙션 뷰나 테이블 뷰 둘다와 함께 양립해서 사용할 수 있다는 이점도 얻을 수 있었는데, 이것이 미래의 기능을위해 리팩토링 한 후에 얻은 레이아웃 유연성이다.

ASDK의 레이아웃은 CSS Flex Box 모델을 기반으로 하는데, 이것은 현재 오토 레이아웃 코드의 시작점중 일부이기도 하다. 그러나 Inset, Overlay, Center, Stack등을 사용하여 레이아웃 기능을 꽤 쉽게 사용할 수 있다. 우리는 빠르게 바뀐 셀을 레이아웃에 구성할 수 있었으며, 아이패드에서 읽을 수 있는 레이아웃 가이드로 방향 전환과 고정된 폭 지원까지 해줌으로서 오토 레이아웃보다 더 깔끔하게 보이게 되었다.

AsyncDisplayKit의 소개때부터 핀터레스트는 페이스북과 협력하여 AsyncDisplayKit v2.0 작업을 함께 하고 있었다. 우리는 항상 페이스북과 핀터레스트 iOS 앱 둘다의 퍼포먼스에 놀랐었다. 핀터레스트 앱에서 순식간에 핀을 통해 스와이핑할때는 정말로 인상적이고, 좀처럼 로딩화면에 도달하지 않는다. 우리가 보았던 그들의 포퍼먼스 증진을 큰 인상을 받아왔는데, Buffer iOS앱의 다른 영역에서 ASDK를 사용하여 탐험할 수 있음에 흥분을 가라앉힐 수 없었다.

아래 비디오는 개발하는 중에 찍었는데, 새로 코딩한 버전의 v5.6 큐를 보여준다. 여러분은 셀 높이를 계산하는 iOS가 스크롤이 점프해서 왔다갔다하는것이 적어지면서, 건더뛰는 프레임도 줄었다는 것을 알 수 있을 것이다.

다음으로 할것 + 여러분의 생각
다른 오픈 소스 애플리케이션을 ASDK 예제와 곧 공유하여 다른 사람들이 자체 애플리케이션에서 ASDK를 사용할 수 있게 도움이 되면 좋겠다. 첫 번째 버전은 Buffer iOS 앱에서 업데이트를 재정렬하는 데 사용되는 코드이다.

프로젝트에서 ASDK를 사용해볼 기회가 있다면, 우리는 여러분의 경험을 들어보고 싶다. 부드러운 스크롤링에대한 다른 팁이나 질문이 있다면 코멘트를 달아달라. 



WRITTEN BY
tucan.dev
개인 iOS 개발, tucan9389

,

※ 번역 작업 처음 해봅니다.

※ 발번역 죄송합니다.. 틀린점 댓글로 달아주시면 수정하겠습니다.

※ 블로그에 글 올리는 것도 처음 해봅니다. 눈에 거슬리는 점이 있어도 너그럽게 이해해주시길 바라겠습니다.




원문 : Streaming Audio to Multiple Listeners via iOS' Multipeer Connectivity

 Tony DiPasquale  November 20, 2013 

Translate by canapio




음악은 아이폰 혹은 모든 애플 제품에서 굉장히 중요한 부분이다. iOS7이 출현하면서, 애플은 NSOutputStreamNSInputStream를 이용하여 데이터 스트림에 접근이 가능한 "Multipeer Connectivity"이라는 새로운 기술을 선보였다. 그러나, NSOutputStream를 이용해 재생하는 것은 쉬운일이 아니었고, 나는 CoreAudio를 이용해 사용할 수 있게 만드는 모험을 시작했다.

개요
Multipeer Connectivity는 NSOutputStream를 이용하여 연결된 요소(connected peer)에 데이터를 스트림한다. 이것은 오디오 데이터를 전송하는데 사용할 것이다. 전송이 끝나고, Multipeer Connectivity는 우리가 incoming data를 얻는데 사용할 NSInputStream 를 쓴다. 애플에서 제공한 Audio Queue Services을 사용하여, 이 데이터를 디바이스 시스템에 보낼 것이다. Audio Queue Services는 버퍼를 치우고 재생까지 할 수 있게 해준다. 이것은 오디오 데이터 열(audio data raw)을 재생시킬 수 있지만, MP3나 AAC와 같은 대부분의 오디오 파일은 크기를 줄이기 위해 인코딩작업이 되어 있다. 애플은 인코딩된 오디오 포맷을 처리하고 오디오 데이터 열을 반환해주는 Audio File Stream Services를 제공했다. 아래 그림은 데이터의 플로우이면서 계획된 솔루션이 실행되기 직전의 모습이다.



첫째로, 오디오 스트림이 시작되고 데이터를 받으면, 디코딩을 해주는 스트림 파서에 넣는다. 이 파서는 우리가 필요로하는 오디오 데이터 열(audio data raw)를 보내준다. 파서로부터 하나씩 받은 세개의 오디오 버퍼 데이터가 오디오 큐(audio queue)안에 있다. 버퍼가 다 차면 시스템으로 보내진다. 그리고 시스템에서 소리를 다 냈으면 다시 돌아와 다시 채워지고, 소리내고 비우고 채우는 과정을 더이상 플레이할 것이 없을 때까지 반복한다. 아래 GIF는 시스템 하드웨어에서 오디오 데이터가 코드에서 어떻게 흘러가는지 보여주는 애니메이션이다. 빨간 박스는 빈 버퍼, 초록 박스는 가득찬 버퍼를 의미한다.




오디오 데이터 보내기
이제 우리는 스티리밍이 백그라운드에서 어떻게 동작하는지 좀 안다. 아이튠즈 라이브러리에서 노래 한곡을 재생해보자. MPMediaPickerController을 사용하여 유저는 노래를 고를 수 있다. 우리는 피커컨트롤러의 델리게이트 메소드(mediaPicker:didPickMediaItems:)를 이용하여 MPMediaItem들을 담은 배열을 얻을 것이다. 
MPMediaItem는 곡의 타이틀, 작사, 작곡 등.. 수많은 프로퍼티를 가지고 있지만, MPMediaItemPropertyAssetURL 프로퍼티에 초점을 둘것이다. AVAssetReader 와 AVAssetReaderTrackOutput 을 사용해서 데이터 파일의 위치를 알아낸 다음 저것(MPMediaItemPropertyAssetURL)을 사용하여 AVURLAsset 를 만들어낸다. 


NSURL *url = [myMediaItem valueForProperty:MPMediaItemPropertyAssetURL];
AVURLAsset *asset = [AVURLAsset URLAssetWithURL:url options:nil];
AVAssetReader *assetReader = [AVAssetReader assetReaderWithAsset:asset error:nil];
AVAssetReaderTrackOutput *assetOutput = [AVAssetReaderTrackOutput assetReaderTrackOutputWithTrack:asset.tracks[0] outputSettings:nil];
[self.assetReader addOutput:self.assetOutput];
[self.assetReader startReading];
이제, 미디어 아이템으로부터 AVURLAsset를 뽑아냈다. 우리는 이걸 사용해서 AVAssetReader 와 AVAssetReaderTrackOutput를 만들것이다. 마지막으로 우리는 읽'어주는 놈(reader)'한테 output을 던저주고 읽게 할 것이다. startReading 이라는 메소드는 읽어주는 놈(reader)을 열고 데이터를 요청했을때를 위해 준비하는 일밖에 하지 않는다.

다음으로 NSOutputStream 을 열고 해당 델리게이트 메서드는 NSStreamEventHasSpaceAvailable 이벤트가 호출 될때까지 읽어주는 놈(reader)의 데이터를 보냅니다.


CMSampleBufferRef sampleBuffer = [assetOutput copyNextSampleBuffer];

CMBlockBufferRef blockBuffer;
AudioBufferList audioBufferList;

CMSampleBufferGetAudioBufferListWithRetainedBlockBuffer(sampleBuffer, NULL, &audioBufferList, sizeof(AudioBufferList), NULL, NULL, kCMSampleBufferFlag_AudioBufferList_Assure16ByteAlignment, &blockBuffer);

for (NSUInteger i = 0; i < audioBufferList.mNumberBuffers; i++) {
    AudioBuffer audioBuffer = audioBufferList.mBuffers[i];  
    [audioStream writeData:audioBuffer.mData maxLength:audioBuffer.mDataByteSize];
}

CFRelease(blockBuffer);
CFRelease(sampleBuffer);
먼저, 리더 아웃풋에서 나온 셈플 버퍼를 가져온다. 그리고나서 오디오 버퍼의 리스트를 얻기 위해 CMSampleBufferGetAudioBufferListWithRetainedBlockBuffer 함수를 호출한다. 마지막으로 아웃풋 스트림의 각 오디오 버퍼를 쓴다(write).

이것이 우리는 처음에 하고자 했던 아이튠즈 라이브러리에 있는 음악을 스트리밍 한 것이다.(재생한건 아님) 이제 이 스트림 데이터를 어떻게 받아내는지 그리고 오디오를 어떻게 재생하는지 보자.


데이터 스트림
Multipeer Connectivity를 사용할 때 부터 NSInputStream 는 이미 만들어져 있었다. 먼저 우리는 데이터를 받기 위해 스트림을 해야한다.
// Start receiving data
// Start receiving data
inputStream.delegate = self;
[inputStream scheduleInRunLoop:[NSRunLoop currentRunLoop] forMode:NSDefaultRunLoopMode];
[inputStream open]; 
이 클래스는  NSStreamDelegate 를 씌울 것이고, 우리는 이제 NSInputStream 으로부터 이벤트를 받을 수 있다.
@interface MyCustomClass () <NSStreamDelegate[CDATA[]]>
//...
@end

@implementation MyCustomClass
//...

- (void)stream:(NSStream *)aStream handleEvent:(NSStreamEvent)eventCode
{
    if (eventCode == NSStreamEventHasBytesAvailable) {
        // handle incoming data
    } elseif (eventCode == NSStreamEventEndEncountered) {
        // notify application that stream has ended
    } elseif (eventCode == NSStreamEventErrorOccurred) {
        // notify application that stream has encountered and error
    }
}

//...
@end

위의 코드를 보면 델리게이트 메소드를 이용해서 스트림으로부터 이벤트를 받는다. 스트림이 끝나거나 에러가 나왔을 때, 앱에 알려야한다. 그래서 다음에 어떤 행동을 취할건지 정해야한다. 이제 우리는 이 이벤트로부터 얻은 스트림이 처리하여 가지고 있는 데이터에만 초점을 두면 된다. 우리는 이 데이터를 가져다가 사용하고, 그다음 Audio File Stream Services에 보내주는 작업이 필요하다.


스트림 파서
스트림 파서는 인코딩된 오디오 데이터를 넣고 디코딩된 오디오 데이터를 얻어오는 AudioFileStream 클래스이다. 먼저 AudioFileStream을 만들어보자.
AudioFileStreamID audioFileStreamID;
AudioFileStreamOpen((__bridge void *)self, AudioFileStreamPropertyListener, AudioFileStreamPacketsListener, 0, &audioFileStreamID);
우리는 클래스에 참조된 파서를 보내고, 프로퍼티는 콜백함수에 의해 바뀌고, 콜백함수에 의해 패킷들을 받는다. 우리는 이러한 기능을 하고 그 클래스에 참조되어 사용될 수 있는 콜백함수가 이 클래스 안에 필요하다.
void AudioFileStreamPropertyListener(void *inClientData, AudioFileStreamID inAudioFileStreamID, AudioFileStreamPropertyID inPropertyID, UInt32 *ioFlags)
{
    MyCustomClass *myClass = (__bridge MyCustomClass *)inClientData;
    [myClass didChangeProperty:inPropertyID flags:ioFlags];
}

void AudioFileStreamPacketsListener(void *inClientData, UInt32 inNumberBytes, UInt32 inNumberPackets, constvoid *inInputData, AudioStreamPacketDescription *inPacketDescriptions)
{
    MyCustomClass *myClass = (__bridge MyCustomClass *)inClientData;
    [myClass didReceivePackets:inInputData packetDescriptions:inPacketDescriptions numberOfPackets:inNumberPackets numberOfBytes:inNumberBytes];
}

didChangeProperty:flags: 메소드 안에서 다른 모든 프로퍼티가 준비됫다고 말해주는 kAudioFileStreamProperty_ReadyToProducePackets 프로퍼티를 찾고 있다. 이제 파서로부터 AudioStreamBasicDescription 를 가져올 수 있다. AudioStreamBasicDescription 는 오디오의 샘플비율(sample rate), 채널, 패킷당 바이트수 등등의 정보를 담겨있고 이것은 오디오 큐(audio queue)를 만드는데 꼭 필요한 요소이다.
AudioStreamBasicDescription basicDescription;
UInt32 basicDescriptionSize = sizeof(basicDescription);
AudioFileStreamGetProperty(audioFileStreamID, kAudioFileStreamProperty_DataFormat, &basicDescriptionSize, &basicDescription);
콜백으로 부터 받은 패킷에 사용될 다른 함수들은 나중에 오디오 큐 버퍼에 쌓일 디코딩된 오디오 데이터를 반환해줄것이다.

이제 스트림의 NSStreamEventHasBytesAvailable 이벤트로부터 인코딩된 데이터를 파서에 넣을 차례이다.
uint8_t bytes[512];
UInt32 length = [audioStream readData:bytes maxLength:512];
AudioFileStreamParseBytes(audioFileStreamID, length, data, 0);
파일 스트림은 파일의 타입을 알기게 충분한 바이트를 가질 때 까지 파싱을 할 것이다. At this point, it invokes its property changed callback with the property kAudioFileStreamProperty_ReadyToProducePackets. 그다음 이것은 우리가 사용하기 좋게 잘 포장된 디코딩된 데이터와 함께 해당 패킷이 받을 콜백을 호출한다.


오디오 큐
오디오 큐는 우리에게 오디오 버퍼를 생성하고, 채우고, 큐에 더할 수 있는것을 허락해주는 AudioQueue 클래스이다. 이것은 또한 재생, 일시정지, 멈춤등과 같은 오디오 컨트롤을 제공한다. 이제 큐와 버퍼를 생성해보자.
AudioQueueRef audioQueue;
AudioQueueNewOutput(&basicDescription, AudioQueueOutputCallback, (__bridge void *)self, NULL, NULL, 0, &audioQueue);

AudioQueueBufferRef audioQueueBuffer;
AudioQueueAllocateBuffer(audioQueue, 2048, &audioQueueBuffer);


오디오 큐를 만들기 위해서는 파서로부터 받은 theAudioStreamBasicDescription를 AudioQueueNewOutput 함수에 전달해 줘야하고, 시스템으로부터 호출된 콜백함수가 버퍼와 클래스 참조를 끝낸다. 다음으로 AudioQueueAllocateBuffer 함수를 호출하여 오디오 큐에 넘겨줄 수 있는 오디오 버퍼 한개를 만들고 잠시 멈추기 위한 버퍼의 사이즈도 함께 넘겨준다.


이제 파서가 패킷을 담은 콜백함수를 호출할 때까지 기다린다. 그리고 빈 버퍼를 패킷으로 채운다. 파서로부터 받을 수 있는 포멧은 VBR과 CBR 두가지 종류가 있는데, Variable Bitrate (VBR)는 비트율이 패킷이 어디있는지 따라 변할 수 있다는 것이고 Constant Bitrate (CBR)은 변하지 않는다(constant)는 것이다.

VBR의 경우,  많은 바이트를 가진 전체 패킷만을 버퍼에 채울 스 있다. 이것은 시스템에서 패킷을 보내주기 전까지는 버퍼가 차지 않는다는걸 의미한다. CBR의 경우, 패킷이 전송되는 도중에 버퍼를 가득 채울 수 있다.


CBR
AudioQueueBufferRef audioQueueBuffer = [self aFreeBuffer];
memcpy((char *)audioQueueBuffer->mAudioData, (constchar *)data,
또한 우리는 버퍼가 오버플로우가 되지 못하게 하거나 이것이 가득 차지 않았을 경우 기다리는 로직이 필요하다.

VBR
AudioQueueBufferRef audioQueueBuffer = [self aFreeBuffer];
memcpy((char *)audioQueueBuffer->mAudioData, (constchar *)(data + packetDescription.mStartOffset), packetDescription.mDataByteSize);

패킷이 버퍼에 넘처 남게되는 것을 체크하는 코드가 있다. 만약 다른 패킷의 mDataByteSize에 맞지 않는다면 우리는 다른 버퍼를 가져와야한다. 또한 패킷 디스크립션(packet descriptions)이 큐 되는동안 기다려야 한다.
바퍼가 차면, AudioQueueEnqueueBuffer 와 함께 시스템에 큐를 날린다.

AudioQueueEnqueueBuffer(audioQueue, audioQueueBuffer, numberOfPacketDescriptions, packetDescriptions);

이제 오디오를 재생할 준비가 끝났다. 모든 버퍼가 채워지고 큐 되면 AudioQueuePrime과 AudioQueueStart
를 사용하여 소리를 재생할 수 있다.
AudioQueueBufferRef audioQueueBuffer = [self aFreeBuffer];
memcpy((char *)audioQueueBuffer->mAudioData, (constchar *)(data + packetDescription.mStartOffset), packetDescription.mDataByteSize);

AudioQueueStart는 두번째 파라메터에 언제 재생될지에대한 시간을 나타내는 값을 NULL대신에 넣을 수 있다. 지금은 별로 중요하지 않으니 넘어가지만, 나중에 오디오 동기화(audio synchronization)을 하는데 꼭 필요한 것이니 기억해두면 좋다.

끝으로
이 글은 Multipeer Connectivity를 이용한 오디오 스트리밍에 대한 기초적인 글이다. 글을 마치면서 나는 조금더 복잡하고 잘 정리된 오픈소스 라이브러리를 민들었다. 좀더 자세한 내용을 알고싶으면, Github에 올라가있는 tonyd256/TDAudioStreamer 다듬어진 코드를 볼 수 있다.



 Tony DiPasquale  Developer

translate by canapio



WRITTEN BY
tucan.dev
개인 iOS 개발, tucan9389

,