效果展示
通過在越獄環境下修改SpringBoard.app,實現了一個iOS桌面的無限屏模式,實拍效果如下:
背景
幾天前錘子舉行了夏季釋出會,筆者抱著聽相聲的心態觀看了釋出會全程,在看到無限屏片段時不禁感嘆老羅的腦洞之大,拋開其實用性不談,筆者對無限屏的原理和實現進行了研究,並在越獄機上完美還原了這一功能。
原理
要實現無限屏,主要有兩點,第一點是一個穩定的慣導演算法來獲取手機的相對位移,第二點是渲染一個遠大於手機螢幕的虛擬空間,使得在視口發生位移時,產生在無限屏上游歷的效果,本文將對這兩點的具體實現進行講解,並在文末開源整個無限屏的實現。
獲取手機的相對位移
ARKit通過雙攝像頭配合或是單攝像頭+陀螺儀配合可以實現較為穩定的視覺里程計,從而能夠檢測到手機在真實世界的姿態和位移,並將其對映到虛擬世界,為了獲取手機的相對位移,我們可以在App中啟動一個ARSession,並通過ARFrame更新的回撥去獲取虛擬世界攝像機的位置資訊,從而計算出相對位移。
在ARKit的虛擬世界中,使用了和陀螺儀一致的右手系,如下圖所示。
在老羅的釋出會演示中我們看到無限屏功能主要包括沿著X軸左右移動視口和沿著Y軸上下移動視口兩部分,因此我們需要通過ARFrame去獲取X軸和Y軸的相對位移。
在ARSession啟動後,會不斷通過回撥通知ARFrame的更新,在回撥方法中我們可以拿到攝像機的transform矩陣,該矩陣的大小為4×4,經過查閱資料瞭解到,矩陣最後一行的前三個元素分別是x、y、z三軸相對AR原點的座標,通過這三個座標我們可以獲取到三軸的相對位置,這一行也被稱為相機的translate向量。
- (void)session:(ARSession *)session didUpdateFrame:(ARFrame *)frame {
matrix_float4x4 mat33 = frame.camera.transform;
simd_float4 pos = mat33.columns[3];
float x = pos[0];
float y = pos[1];
float z = pos[2];
}
複製程式碼
需要注意的是這三個座標都是相對ARKit所確定的原點計算出來的,我們現在需要以當前位置為原點計算手機的相對移動,因此需要對資料的原點進行重新標定,一個簡易的方法是在ARFrame初始化完成後將當前的x、y、z三軸位置記錄下來作為標定點A(x0, y0, z0)
,後續在計算時都相對A點去計算。
ARKit在初始化階段時translate向量將返回全0,因此我們將translate首次不為0作為初始化完成的標識,標定A點,並開始相對位置的輸出,程式碼如下。
// 用於計算三軸資料的變數
@property (nonatomic, assign) float x_pre;
@property (nonatomic, assign) float x_base;
@property (nonatomic, assign) BOOL hasInitX;
@property (nonatomic, assign) BOOL findXBase;
@property (nonatomic, assign) float y_pre;
@property (nonatomic, assign) float y_base;
@property (nonatomic, assign) BOOL hasInitY;
@property (nonatomic, assign) BOOL findYBase;
@property (nonatomic, assign) float z_pre;
@property (nonatomic, assign) float z_base;
@property (nonatomic, assign) BOOL hasInitZ;
@property (nonatomic, assign) BOOL findZBase;
// val: camera某個軸向的實際座標值
// pre: 上一個camera座標值
// base: 標定後的原點
// hasInit: 是否完成了某軸向的初始化
// findBase: 是否完成了某軸向的標定
float calculateOffset(float val, float *pre, float *base, BOOL *hasInit, BOOL *findBase) {
// 判斷translate某軸向的值是否非0,非0說明ARKit完成了初始化
if (!(*hasInit) && val < 0.0000001f) {
NSLog(@"init");
return 0;
} else {
*hasInit = YES;
}
// 判斷ARKit某軸向的兩次輸出是否差值很小,差值很小時說明已經穩定,將當前位置標定為當前軸向的原點
if (!(*findBase) && fabs(val - *pre) < 0.01f) {
NSLog(@"value is stable at %f", val);
*base = val;
*findBase = YES;
return 0;
}
// 計算實際translate和標定點之間的距離
float offset = val - *base;
*pre = val;
return offset;
}
- (void)session:(ARSession *)session didUpdateFrame:(ARFrame *)frame {
matrix_float4x4 mat33 = frame.camera.transform;
simd_float4 pos = mat33.columns[3];
// ARCamera的translate
float x = pos[0];
float y = pos[1];
float z = pos[2];
// 計算相對手機當前位置的偏移量
float offsetX = calculateOffset(x, &_x_pre, &_x_base, &_hasInitX, &_findXBase);
float offsetY = calculateOffset(y, &_y_pre, &_y_base, &_hasInitY, &_findYBase);
float offsetZ = calculateOffset(z, &_z_pre, &_z_base, &_hasInitZ, &_findZBase);
// 輸出穩定的三軸偏移(offsetX, offsetY, offsetZ)
}
複製程式碼
上面的程式碼由於需要在函式內修改全域性變數而變得較為混亂,基本型別通過指標來回傳遞,不夠優雅,總之每個軸向都有三個關鍵全域性變數,hasInit用於表示ARKit是否完成初始化,findBase用於表示是否已經完成了標定,pre值用於記錄上一次輸出來檢測ARKit輸出穩定的時機,通過這三個變數配合即可完成原點標定,從而使得隨後能夠獲取以手機當前位置為原點的三軸偏移量。
渲染虛擬空間
無限屏的實現類似於用手機瀏覽器檢視電腦版網頁的效果,以手機螢幕為尺寸作為一個視口,在一個大於手機螢幕的範圍內進行瀏覽,實際上是視口的位置發生了變換,可以理解為一個垂直向下拍攝的攝像機在一個巨幅圖片上進行移動。
對於SpringBoard.app,它實際上是一個巨幅的UIScrollView,因此它本身就是這個比螢幕尺寸大的虛擬空間,它包含了-1屏和多屏桌面,但是為了實現一些3D效果,筆者選擇了對SpringBoard的ScrollView進行截圖,在真實遊歷時,實際上是隱藏了真實的桌面,顯示了一幅”假桌面”,為了方便期間我們稱其為FakeScrollView,FakeScrollView上新增的是經過處理後的真實桌面截圖。
擷取一個UIScrollView的全貌
通過Layer的渲染方法可以將UIScrollView的整個contentSize範圍繪製到一個圖形上下文中,程式碼如下。
// scrollView是SpringBoard.app的桌面SBIconScrollView
CGRect rect = (CGRect){0, 0, scrollView.contentSize};
UIGraphicsBeginImageContextWithOptions(rect.size, false, [UIScreen mainScreen].scale);
[scrollView.layer renderInContext:UIGraphicsGetCurrentContext()];
UIImage *desktopImage = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
複製程式碼
在桌面圖片上下新增相機和地圖區域
在釋出會上,老羅演示了上移手機自拍和下移手機開啟地圖的功能,為了還原這一功能,筆者將上述操作獲取的桌面截圖desktopImage進行了二次處理,利用CoreGraphics在圖片上方繪製一個topImage,下方繪製一個bottomImage,topImage的內容為一排相機Icon,bottomimage的內容為一排地球Icon,要實現圖片拼接,需要開一個更大的圖形上下文,然後依次將圖片渲染到指定位置,完整程式碼如下。
// 擷取桌面,作為大圖的中間部分middleImage
CGRect rect = (CGRect){0, 0, scrollView.contentSize};
UIGraphicsBeginImageContextWithOptions(rect.size, false, [UIScreen mainScreen].scale);
[scrollView.layer renderInContext:UIGraphicsGetCurrentContext()];
UIImage *middleImage = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
// 從資原始檔讀取相機和地球,USBResource是一個資源獲取的輔助類
UIImage *topImage = [USBResource imageNamed:@"camera.png"];
UIImage *bottomImage = [USBResource imageNamed:@"earth.png"];
// 上下檢視的垂直間距
CGFloat imageMargin = 320;
// 相機和地球平鋪的水平間距
CGFloat marginH = 80;
// 具體位置計算
CGFloat topImageW = 120;
CGFloat topImageH = 89;
CGFloat bottomImageW = 120;
CGFloat bottomImageH = 120;
// 用於渲染完整圖片的上下文
CGSize ctxSize = CGSizeMake(middleImage.size.width, middleImage.size.height + topImageH + bottomImageH + imageMargin * 4);
UIGraphicsBeginImageContextWithOptions(ctxSize, NO, [UIScreen mainScreen].scale);
// add top image: camera
CGFloat topImageX = marginH;
CGFloat topImageY = topImageH + imageMargin;
NSInteger count = (ctxSize.width - marginH) / (topImageW + marginH);
for (NSInteger i = 0; i < count; i++) {
[topImage drawInRect:CGRectMake(topImageX, topImageY, topImageW, topImageH)];
topImageX += topImageW + marginH;
}
// add middle image: desktop
[middleImage drawInRect:CGRectMake(0, topImageH + imageMargin * 2, middleImage.size.width, middleImage.size.height)];
// add bottom image: earth
CGFloat bottomImageX = marginH;
CGFloat bottomImageY = ctxSize.height - imageMargin - bottomImageH;
count = (ctxSize.width - marginH) / (bottomImageW + marginH);
for (NSInteger i = 0; i < count; i++) {
[bottomImage drawInRect:CGRectMake(bottomImageX, bottomImageY, bottomImageW, bottomImageH)];
bottomImageX += bottomImageW + marginH;
}
// 獲取到的"假桌面"圖片
UIImage *snapshot = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
複製程式碼
隨後只需要將snapshot圖片新增到FakeScrollView,在開啟無限屏模式時隱藏真實桌面SBIconScrollView,顯示FakeScrollView即可,為了更好地效果,這裡對FakeScrollView和snapshot圖片都進行了一些3D的仿射變換,最終效果如下圖所示。這部分程式碼可以在文末的原始碼中檢視,這裡不再贅述,
實現
由於需要修改SpringBoard.app,本文建立在越獄環境的基礎之上,如果讀者沒有越獄環境也沒有關係,可以將修改的目標變為自己所寫的App,比如實現一個可以左右、上下翻閱的地圖、PDF閱讀器等,本文的實現部分主要介紹如何修改SpringBoard.app從而達到上述效果。
知識儲備和環境
- 越獄開發的基礎知識,SSH、SCP、動態庫載入實現Hook等
- 支援ARKit的iPhone或iPad
- 越獄的iPhone或iPad Electra Jailbreak
- Theos開發環境 theos.github.io
- MonkeyDev開發環境 github.com/AloneMonkey…
其中MonkeyDev是為了簡化Theos的編譯連結和部署流程,不是必須的環境,但是缺少該環境會導致無法正常執行文末的Xcode工程,需要手動去編譯出deb並安裝,MonkeyDev將整個過程變得自動化。
Hook SpringBoard
筆者通過Theos提供的Logos語言對SpringBoard的桌面檢視SBIconScrollView進行了hook,由於桌面進行了分頁(Paging),因此啟動時一定會呼叫UIScrollView的- (void)setPagingEnabled:(BOOL)enabled
方法,我們就以這個方法作為Hook的起點,注意以下程式碼都是Logos語言。
%hook SBIconScrollView
- (void)setPagingEnabled:(BOOL)enabled {
static const void *key;
// 利用關聯物件實現防止重複呼叫
if (objc_getAssociatedObject(self, key) != nil) {
%orig(enabled);
return;
}
// 在這裡完成初始化
// ...
objc_setAssociatedObject(self, key, @"", OBJC_ASSOCIATION_RETAIN);
%orig(enabled);
}
%end
複製程式碼
上述程式碼為我們在SBIconScrollView上開闢了一個程式碼執行的入口,隨後我們可以根據當前ScrollView去找到ViewController和Window,通過Reveal分析,桌面的根視窗為SBHomeScreenWindow
,下面的程式碼演示瞭如何找到這個視窗並記錄下來,方便後續操作。
for (UIWindow *window in [UIApplication sharedApplication].windows) {
if ([window isKindOfClass:NSClassFromString(@"SBHomeScreenWindow")]) {
// 找到關鍵的視窗和控制器
UIWindow *mainWindow = window;
UIViewController *mainVc = window.rootViewController;
break;
}
}
複製程式碼
由於動態庫並不能為Hook的類動態新增例項變數,因此這裡只能通過Runtime的關聯物件去記錄這些關鍵資訊,大量的關聯物件將使得程式碼不夠優雅,另一個更好地方案是使用一個全域性的單例物件去維護這些資訊。
進入和退出無限屏模式
進入無限屏模式,即將Hook的類直接隱藏,在Window上新增一個FakeScrollView,並開啟ARSession進行位置追蹤;反之,退出無限屏模式即是對關閉ARSession,還原現場。
動態庫的資源訪問
由於動態庫以dylib的形式直接插入到Mach-O檔案的LOAD_COMMANDS欄位,所以在載入時無法攜帶資源,一個比較優雅的方式是將資源以bundle的形式放置在dylib的安裝目錄,並在dylib中以絕對路徑進行訪問,越獄環境下dylib的安裝目錄為/Library/MobileSubstrate/DynamicLibraries
,在這裡放置一個資源bundle,並且封裝一個資源訪問類,程式碼如下。
#import "USBResource.h"
#define BundlePath @"/Library/MobileSubstrate/DynamicLibraries/UltimateSpringBoard.bundle"
@implementation USBResource
+ (UIImage *)imageNamed:(NSString *)name {
return [UIImage imageWithContentsOfFile:[BundlePath stringByAppendingPathComponent:name]];
}
@end
複製程式碼
為SpringBoard新增許可權
由於ARKit需要使用相機,需要為SpringBoard新增一條許可權,這需要直接修改SpringBoard的Info.plist,不必擔心,系統App和自己開發App的Info.plist並沒有進行程式碼簽名,直接修改即可,為了防止出現意外,建議備份一份Info.plist以防不測。
首先用SSH登入到iPhoen或iPad,用ps -ef | grep SpringBoard
查詢SpringBoard.app的路徑,然後進入該路徑,將Info.plist用scp命令或者SFTP客戶端傳輸到電腦,通過Xcode為其新增NSCameraUsageDescription
條目,然後利用scp回傳後覆蓋即可。
安全模式
由於直接修改了SpringBoard.app,如果出現嚴重bug但沒有引起SpringBoard Crash,會導致無法進入越獄系統的SpringBoard安全模式,這會使得在脫離電腦的情況下無法重啟SpringBoard,假如這時候SpringBoard無法正常點選,則會導致手機無法正常使用,因此需要設計一個”自殺”功能,來使得外掛能夠自動重啟SpringBoard,筆者所用的方案是在SpringBoard上新增一個按鈕,點選後執行exit(0)
,隨後系統會自動重啟SpringBoard,具體程式碼如下。
// 新增一個Respring按鈕
UIButton *closeBtn = [UIButton new];
// ...省略配置過程
[closeBtn addTarget:self action:@selector(closeBtnClick) forControlEvents:UIControlEventTouchUpInside];
[window addSubview:closeBtn];
// 回撥方法
%new
- (void)closeBtnClick {
exit(0);
}
複製程式碼
原始碼與執行
原始碼下載
配置
- 開啟Xcode工程
- 開啟UltimateSpringBoard Target的Build Settings,配置User-Defined的Settings中的MonkeyDevDeviceIP、Port等資訊,這些資訊用於在Theos構建後自動將deb傳輸和安裝到手機
- 將工程根目錄下的
arch/UltimateSpringBoard.bundle
利用scp命令傳輸到/Library/MobileSubstrate/DynamicLibraries/
目錄,這些是外掛需要訪問的資源 - 為SpringBoard.app的Info.plist新增
NSCameraUsageDescription
許可權 - Build工程即可完成安裝
手動編譯和安裝
- 工程的Packages目錄中包含了編譯好的deb包,可以直接體驗
- UltimateSpringBoard.xm是Logos主檔案,可以用Theos手動編譯
感想
也許無限屏並不能帶來什麼,但是這個探索過程是十分有趣的,希望本文能夠幫助那些好奇無限屏實現原理和想要實踐越獄外掛開發的同學們。