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

效果展現

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

背景

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

原理

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

獲取手機的相對位移

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

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

在老羅的發佈會演示中咱們看到無限屏功能主要包括沿着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點去計算。session

ARKit在初始化階段時translate向量將返回全0,所以咱們將translate首次不爲0做爲初始化完成的標識,標定A點,並開始相對位置的輸出,代碼以下。app

// 用於計算三軸數據的變量
@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輸出穩定的時機,經過這三個變量配合便可完成原點標定,從而使得隨後可以獲取以手機當前位置爲原點的三軸偏移量。ide

渲染虛擬空間

無限屏的實現相似於用手機瀏覽器查看電腦版網頁的效果,以手機屏幕爲尺寸做爲一個視口,在一個大於手機屏幕的範圍內進行瀏覽,其實是視口的位置發生了變換,能夠理解爲一個垂直向下拍攝的攝像機在一個巨幅圖片上進行移動。

對於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從而達到上述效果。

知識儲備和環境

其中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手動編譯

感想

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

相關文章
相關標籤/搜索