歡迎光臨
每天分享高質量文章

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

作者:Soulghost

鏈接:https://juejin.im/post/5b801cede51d4538a108af56


效果展示

通過在越獄環境下修改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 (nonatomicassignfloat x_pre;
@property (nonatomicassignfloat x_base;
@property (nonatomicassignBOOL hasInitX;
@property (nonatomicassignBOOL findXBase;

@property (nonatomicassignfloat y_pre;
@property (nonatomicassignfloat y_base;
@property (nonatomicassignBOOL hasInitY;
@property (nonatomicassignBOOL findYBase;

@property (nonatomicassignfloat z_pre;
@property (nonatomicassignfloat z_base;
@property (nonatomicassignBOOL hasInitZ;
@property (nonatomicassignBOOL 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){00, 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){00, 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     [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     [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);
}


原始碼與運行

原始碼下載

https://github.com/Soulghost/InfiniteSpringBoard

配置

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

感想

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


編號286,輸入編號直達本文

●輸入m獲取文章目錄

推薦↓↓↓

Web開發

更多推薦18個技術類微信公眾號

涵蓋:程式人生、演算法與資料結構、黑客技術與網絡安全、大資料技術、前端開發、Java、Python、Web開發、安卓開發、iOS開發、C/C++、.NET、Linux、資料庫、運維等。

赞(0)

分享創造快樂