作者:Soulghost;

來源:https://juejin.im/post/5b801cede51d4538a108af56


效果展示

通過在越獄環境下修改SpringBoard.app,實現了一個iOS桌面的無限屏模式,實拍效果如下:


基於ARKit的iOS無限屏實現,還原錘子發佈會效果



背景

幾天前錘子舉行了夏季發佈會,筆者抱着聽相聲的心態觀看了發佈會全程,在看到無限屏片段時不禁感嘆老羅的腦洞之大,拋開其實用性不談,筆者對無限屏的原理和實現進行了研究,並在越獄機上完美還原了這一功能。

原理

要實現無限屏,主要有兩點,第一點是一個穩定的慣導算法來獲取手機的相對位移,第二點是渲染一個遠大於手機屏幕的虛擬空間,使得在視口發生位移時,產生在無限屏上游歷的效果,本文將對這兩點的具體實現進行講解,並在文末開源整個無限屏的實現。

獲取手機的相對位移

ARKit通過雙攝像頭配合或是單攝像頭+陀螺儀配合可以實現較爲穩定的視覺里程計,從而能夠檢測到手機在真實世界的姿態和位移,並將其映射到虛擬世界,爲了獲取手機的相對位移,我們可以在App中啓動一個ARSession,並通過ARFrame更新的回調去獲取虛擬世界攝像機的位置信息,從而計算出相對位移。

在ARKit的虛擬世界中,使用了和陀螺儀一致的右手系,如下圖所示。


基於ARKit的iOS無限屏實現,還原錘子發佈會效果



在老羅的發佈會演示中我們看到無限屏功能主要包括沿着X軸左右移動視口和沿着Y軸上下移動視口兩部分,因此我們需要通過ARFrame去獲取X軸和Y軸的相對位移。

在ARSession啓動後,會不斷通過回調通知ARFrame的更新,在回調方法中我們可以拿到攝像機的transform矩陣,該矩陣的大小爲4x4,經過查閱資料瞭解到,矩陣最後一行的前三個元素分別是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的仿射變換,最終效果如下圖所示。這部分代碼可以在文末的源碼中查看,這裏不再贅述


基於ARKit的iOS無限屏實現,還原錘子發佈會效果


基於ARKit的iOS無限屏實現,還原錘子發佈會效果


實現

由於需要修改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);
}


源碼與運行

源碼下載

github.com/Soulghost/I…

配置

  1. 打開Xcode工程
  2. 打開UltimateSpringBoard Target的Build Settings,配置User-Defined的Settings中的MonkeyDevDeviceIP、Port等信息,這些信息用於在Theos構建後自動將deb傳輸和安裝到手機
  3. 將工程根目錄下的arch/UltimateSpringBoard.bundle利用scp命令傳輸到/Library/MobileSubstrate/DynamicLibraries/目錄,這些是插件需要訪問的資源
  4. 爲SpringBoard.app的Info.plist添加NSCameraUsageDescription權限
  5. Build工程即可完成安裝


手動編譯和安裝

  • 工程的Packages目錄中包含了編譯好的deb包,可以直接體驗
  • UltimateSpringBoard.xm是Logos主文件,可以用Theos手動編譯


感想

也許無限屏並不能帶來什麼,但是這個探索過程是十分有趣的,希望本文能夠幫助那些好奇無限屏實現原理和想要實踐越獄插件開發的同學們。

相關文章