본문 링크 (Original Link)

아이폰 X 홈 인디케이터 파헤치기

2018.01.28

#

by Sash Zats, translated by pilgwon

아이폰 X의 홈 인디케이터가 물리 버튼을 대신하자마자, 저는 그것의 속성에 대해 궁금해졌습니다. 홈 인디케이터는 임의의 배경의 잠금 화면에서도 나와야하며 비디오나 게임과 같은 임의의 컨텐츠를 보여주는 써드 파티 앱에서도 선명하게 보입니다. 게다가 빠르게 바뀝니다.

분명히 UIKit에는 비슷한 기능도 존재하지 않습니다. 그래서 이번엔 아이폰 X의 홈 인디케이터가 어떻게 만들어졌는지에 대해 알아볼 것입니다!

홈 인디케이터 클래스 찾기

비슷한 코드라도 찾기 위해서, 저는 비슷한 UI 요소를 생각해봤습니다. 처음엔 시스템 상태바가 가장 비슷하다고 생각했습니다. 홈 인디케이터도 항상 잠금 화면에 있으며, 모든 앱 화면에 보여집니다. 저의 첫번째 바와 상관있는 코드였습니다. GitHub에서 찾을 수 있는 UIKit의 해더를 봤지만 저는 새로운 홈 인디케이터와 비슷한 것을 아무것도 찾을 수 없었습니다. 다음은 스프링보드(CoreServices 폴더에 살고있으며 잠금 화면과 홈 화면의 다양한 시스템의 기능을 대응하는 “앱”입니다.)를 알아보기로 했습니다. 스프링보드에 포함돼있는 덤핑 클래스인 class-dump ($ brew install class-dump) 에서 SBHomeGrabberView 라는 흥미로운 것을 발견했습니다. 매우 좋은 시작이네요.

$ class-dump /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/Library/CoreSimulator/Profiles/Runtimes/iOS.simruntime/Contents/Resources/RuntimeRoot/System/Library/CoreServices/SpringBoard.app/SpringBoard

...

@interface SBHomeGrabberView : UIView <_UISettingsKeyPathObserver, SBAttentionAwarenessClientDelegate, MTLumaDodgePillBackgroundLuminanceObserver>
{
    SBHomeGrabberSettings *_settings;
    MTLumaDodgePillView *_pillView;
    SBAttentionAwarenessClient *_idleTouchAwarenessClient;
    _Bool _touchesAreIdle;
    _Bool _autoHides;
    long long _luma;
    unsigned long long _suppressLumaUpdates;
}

@property(nonatomic) _Bool autoHides; // @synthesize autoHides=_autoHides;
- (void).cxx_destruct;
- (void)lumaDodgePillDidDetectBackgroundLuminanceChange:(id)arg1;

다음으로, 우리의 더미 앱에 스프링보드의 모든 코드를 불러와서 윈도우에 뷰를 추가하고 이것이 우리가 실제로 흥미로워 하던 그것인지 확인해봅시다. 코드가 더 깨끗할 순 있지만, 필수 아이디어는 다음과 같습니다.

#import <dlfcn.h>

// somewhere in viewDidLoad
dlopen([binaryPath cStringUsingEncoding:NSUTF8StringEncoding], RTLD_NOW);
UIView *const view = [[NSClassFromString(@"SBHomeGrabberView") alloc] init];
[view sizeToFit];
[self.view addSubview:view];

약간의 수정을 거쳐, 우리가 얻은 결과는 다음과 같습니다.

이제 우리가 찾던게 어떤건지 알았으니 어떻게 만드는지 알아봅시다. 구체적인 구현 방법을 알기 위해 저는 Hopper Disassembler를 사용했습니다. (무료 버전이었지만 우리의 목적에는 충분했습니다.) 이 앱은 역어셈블리어를 읽을 때 생기는 손실을 줄여줍니다. 우리는 그저 바이너리를 열고 당신을 흥미롭게 하는 메소드를 찾으면 됩니다. 그곳에 도착한 후에, 윗부분에 있는 수도 코드를 토글하세요. 그러면 Objective-C, C++ 그리고 어셈블리로 이루어진 읽기 편한 코드를 보여줄 것입니다.

  1. 구현된 모든 메소드를 보기 위해 모든 클래스의 이름을 적었습니다.

  2. 시간이 지남에 따라 우리는 직감을 키우고 “흥미로운” 메소드를 찾는 능력을 배우게 될 것입니다. 애플의 엔지니어들도 공용 UIKit 메소드를 사용하기 때문에 전혀 문제 없습니다. 이것이 제가 -[SBHomeGrabberView initWithFrame:] 부터 알아본 이유입니다.

  3. 어셈블리를 보는데 익숙하지 않다면 수도 코드 모드로 변경하세요.

  4. 가능한 한 많은 것을 만들도록 노력하세요. 떄로는 코드가 꽤 자명한데 가끔씩 막다른 골목에서 자신을 발견할 수도 있습니다.

개인적인 참고를 적자면, 저는 자세한 구현 방식을 읽는 것을 매우 흥미로워 합니다. 때론 “재미를 위해” 하기도 하고, 아니면 어떠한 행동을 더욱 더 잘 이해하고 싶을 때 합니다.

SBHomeGrabberView 로 돌아가서, 우리는 그것이 얇은 껍데기라는 것과 MTLumaDodgePillView 서브뷰를 추가한다는 것도 알게 되었습니다. 처음엔 메탈 프레임워크라고 생각했지만(MTL 접두사 때문에), 하지만 그것은 메탈 프레임워크처럼 또 다른 하나의 “로우 레벨” 프레임워크 중 하나라는 것을 알게되었습니다. 게다가 트위터의 Matthias가 지적한 바에 따르면 그들 클래스의 접두사는 MTL이 아니라 MT 라고 합니다. 🤦‍♂️ 운 좋게 당신의 앱에 스프링보드와 같은 바이너리를 불러왔다면 당신은 스프링보드가 그 후에 불러오는 모든 라이브러리에 접근할 수 있을 것입니다. 이것은 dladdr 을 사용하것 처럼 클래스를 정의하는 라이브러리를 찾을 수 있게 해줍니다.

const Class MTLumaDodgePillViewClass = NSClassFromString(@"MTLumaDodgePillView");
Dl_info dlInfo;
dladdr((__bridge void *)MTLumaDodgePillViewClass, &dlInfo);
dlInfo.dli_fname; // path to the binary defining the symbol (class in this case)

이 코드는 조사하기 위해 설정한 앱의 한 부분으로 실행시킬 수 있습니다. 또한 lldb 도 사용할 수 있습니다. 많이 알려지지 않은 lldb의 기능 중 하나는 변수를 설정할 수 있다는 것입니다. lldb를 사용하는 것의 장점은 앱을 다시 컴파일 하지 않아도 된다는 점이고 단점은 lldb는 헤더 파일에 접근할 수 없기 때문에 타입에 대한 도움말이 추가로 필요해서 추가적인 변수 및 함수 반환 타입을 전달한다는 것입니다.

(lldb) e Dl_info $dlInfo
(lldb) e (void)dladdr((__bridge void *)NSClassFromString(@"MTLumaDodgePillView"), & $dlInfo);
(lldb) p $dlInfo.dli_fname
(const char *) $1 = 0x00006000001fd900 "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/Library/CoreSimulator/Profiles/Runtimes/iOS.simruntime/Contents/Resources/RuntimeRoot/System/Library/PrivateFrameworks/MaterialKit.framework/MaterialKit"

코드에 나와있듯이 그것은 /System/Library/PrivateFrameworks/MaterialKit.framework/MaterialKit 에 정의되어 있습니다.

@class MTLumaDodgePillView;

@protocol MTLumaDodgePillBackgroundLuminanceObserver <NSObject>
- (void)lumaDodgePillDidDetectBackgroundLuminanceChange:(MTLumaDodgePillView *)arg1;
@end

@interface MTLumaDodgePillView : UIView
@property(nonatomic, weak) id <MTLumaDodgePillBackgroundLuminanceObserver> backgroundLumninanceObserver;
@property(nonatomic) MTLumaDodgePillViewStyle style;
@property(nonatomic, readonly) MTLumaDodgePillViewBackgroundLuminance backgroundLuminance;
@end

MTLumaDodgePillViewStyleMTLumaDodgePillViewBackgroundLuminance의 가능한 값들을 알아보려면 설명 메소드를 보는 것만으로도 충분합니다. 그것은 정수 값을 우리가 앞으로 사용할 상수인 스트링으로 변환시킵니다.

typedef NS_ENUM(NSInteger, MTLumaDodgePillViewStyle) {
 MTLumaDodgePillViewStyleNone = 0,
 MTLumaDodgePillViewStyleThin = 1,
 MTLumaDodgePillViewStyleGray = 2,
 MTLumaDodgePillViewStyleBlack = 3,
 MTLumaDodgePillViewStyleWhite = 4,
};

typedef NS_ENUM(NSInteger, MTLumaDodgePillViewBackgroundLuminance) {
 MTLumaDodgePillViewBackgroundLuminanceUnknown = 0,
 MTLumaDodgePillViewBackgroundLuminanceDark = 1,
 MTLumaDodgePillViewBackgroundLuminanceLight = 2,
};

마지막으로 흥미로운 것은 API가 backgroundLumninanceObserver 이고, 우리의 뷰가 외형을 변경할 때마다 콜백을 부릅니다.

우리만의 MTLumaDodgePillView 만들기

점점 다가갈수록 MTLumaDodgePillViewStyle 는 정렬들의 껍데기에 불과하다는 사실을 알았습니다. 내부적으로 많은 프라이빗 클래스가 있습니다. 그것은 CABackdropLayer 의 호출을 대신하고(프라이빗, iOS 7 이상), kCAFilterHomeAffordanceBase 를 포함해 다양한 CAFilter 를 사용합니다(프라이빗, iOS 2 이상). CABackdropLayer 는 iOS 7 부터 생긴 다양한 블러 효과들을 담당합니다. 간단히 말하면 그것은 뷰 계층구조를 레이어 뒤에서 복사하고, 컨텐츠에 대한 통계를 모읍니다. 적용된 필터와 함께 뷰 계층 구조를 복제하는 것은 UIVisualEffectView 에 의해 제공되는 모든 다양한 효과들을 제공할 수 있게 해줍니다. 다음은 기본적인 블러 예제입니다.

UIBlurEffect *blur = [UIBlurEffect effectWithStyle:UIBlurEffectStyleLight];
UIVisualEffectView *blurView = [[UIVisualEffectView alloc] initWithEffect:blur];

그 모든 것을 위해 필요한 것은 가우시안 블로, 포화 필터 그리고 블렌드 모드에서 소스를 사용해서 구성된 단색의 흰색입니다. 다음은 그 필터링 부분에 대한 러프한 코드입니다.

CAFilter *const blur = [(id)NSClassFromString(@"CAFilter") filterWithType:kCAFilterGaussianBlur];
[blur setValue:@30 forKey:@"inputRadius"];
CAFilter *const saturate = [(id)NSClassFromString(@"CAFilter") filterWithType:kCAFilterColorSaturate];
[saturate setValue:@1.8 forKey:@"inputAmount"];
CABackdropLayer *backdrop = [NSClassFromString(@"CABackdropLayer") new];
backdrop.filters = @[ blur, saturate ];

CALayer *overlay = [CALayer new];
overlay.backgroundColor = [UIColor colorWithWhite:1 alpha:0.3].CGColor;
overlay.compositeFilter = [(id)NSClassFromString(@"CAFilter") filterWithType:kCAFilterSourceOver];

[layer addSublayer:backdrop];
[layer addSublayer:overlay];

하나로 합치기

마지막 한 걸음은 -[MTLumaDodgePillView initWithFrame:] 를 여는 것입니다. 그것은 MaterialKit가 효과를 복제하기 위해 사용하는 필터의 목록을 보여줍니다.

CAFilter *const blur = [(id)NSClassFromString(@"CAFilter") filterWithType:kCAFilterGaussianBlur];
CAFilter *const colorBrightness = [(id)NSClassFromString(@"CAFilter") filterWithType:kCAFilterColorBrightness];
CAFilter *const colorSaturate = [(id)NSClassFromString(@"CAFilter") filterWithType:kCAFilterColorSaturate];

각 필터의 실제값을 얻으려면 뷰 디버거를 사용하여 일시 중지한 후에 추가된 뷰 중 하나를 선택하고 오른쪽의 뷰 또는 레이어 섹션에서 주소를 복사하세요.

이제 콘솔에서 선택한 주소를 뷰와 레이어에 대한 참조처럼 사용할 수 있습니다.

(lldb) po 0x7fc81331a8a0
<MTLumaDodgePillView:0x7fc81331a8a0 frame={\{120.5, 107.5}, {134, 5}\} style=white backgroundLuminance=unknown>

(lldb) po ((CALayer *)0x600000226d60).filters
<__NSArrayI 0x60000005e450>(
homeAffordanceBase,
gaussianBlur,
colorBrightness,
colorSaturate
)

(lldb) po [((CALayer *)0x600000226d60).filters[0] valueForKey:@"inputAmount"]
1

(lldb) po [((CALayer *)0x600000226d60).filters[0] valueForKey:@"inputAddWhite"]
0.71

이미 아시듯이 우리는 정수에 그들의 속성을 호출할 때 타입을 캐스팅합니다. 이것은 포인터 뒤에 있는 객체의 타입을 알기 위해 lldb를 도우는 일입니다.

모든 속성에 valueForKey: 의 춤을 반복한 후에 우리는 -[MTLumaDodgePillView initWithFrame:] 에서 무언가 발견했습니다. 그것은 약간 지루하지만 저는 오리지널 스타일 정의 파일을 찾고 싶지는 않았습니다 (그것이 어딘가에 plist 형태로 있다는 가정하에 말이죠). 그 일이 끝난 후에 우리는 뷰를 QuartzCore로 재구축할 수 있었습니다.

CAFilter *const homeAffordanceBase = [(id)NSClassFromString(@"CAFilter") filterWithType:kCAFilterHomeAffordanceBase];
UIImage *const lumaDodgeMap = [UIImage imageNamed:@"lumaDodgePillMap" inBundle:[NSBundle bundleForClass:viewClass] compatibleWithTraitCollection:nil];
[homeAffordanceBase setValue:(__bridge id)lumaDodgeMap.CGImage forKey:@"inputColorMap"];
CAFilter *const blurFilter = [(id)NSClassFromString(@"CAFilter") filterWithType:kCAFilterGaussianBlur];
CAFilter *const colorBrightness = [(id)NSClassFromString(@"CAFilter") filterWithType:kCAFilterColorBrightness];
CAFilter *const colorSaturate = [(id)NSClassFromString(@"CAFilter") filterWithType:kCAFilterColorSaturate];

// MTLumaDodgePillViewStyleThin values
[homeAffordanceBase setValue:@0.31 forKey:@"inputAmount"];
[homeAffordanceBase setValue:@0.37275 forKey:@"inputAddWhite"];
[homeAffordanceBase setValue:@0.4 forKey:@"inputOverlayOpacity"];
[blurFilter setValue:@10 forKey:@"inputRadius"];
[blurFilter setValue:@YES forKey:@"inputHardEdges"];
[colorBrightness setValue:@0.06 forKey:@"inputAmount"];
[colorSaturate setValue:@1.15 forKey:@"inputAmount"];

CALayer *layer = [NSClassFromString(@"CABackdropLayer") new];
layer.frame = CGRectInset(self.view.bounds, 100, 100);
layer.filters = @[ homeAffordanceBase, blurFilter, colorSaturate, colorSaturate ];
layer.cornerRadius = 10;
[self.view.layer addSublayer:layer];

홈 기초 필터의 가장 미스테리는 lumaDodgePullMap 이미지를 입력 이미지로 사용한다는 점입니다. UIVisualEffectView 의 구현 방식에 쓰이는 다른 필터의 사용법은 다 이해가 됐습니다. 여기까지입니다, 다음은 우리의 마지막 결과입니다.

동영상 보러가기

후기

이 글이 여러분이 생각했던 리버스 엔지니어링이 생각보다 어렵지 않다는 것을 보여줬기를 바랍니다. Objective-C와 많은 양의 정보가 바이너리에 담겨있습니다. 그것은 리버스 엔지니어링을 신나는 모험으로 만들어줍니다! 여러분의 생각을 알려주시거나 궁금한 점이 있으시다면 트위터 @zats로 알려주세요.