跨平臺的 Hybrid 混合式開發技術棧,一直是一項很是受業界歡迎的技術。然而,許多投身其中的前端開發者每每只熟悉其中的 JS 部分,對於整個應用中基礎性的原生部分了解很是有限,這是十分惋惜的。html
做爲一名前端開發者,我在過去的一年中都在開發 Hybrid 應用框架(參見個人 QCon Plus 分享)。這個過程當中我(不得不)學習了許多與原生應用開發相關的知識。在終於把許多點串起來以後,我發現原生應用與 Web 應用之間其實共享着類似的理念模型,兩者間本質上並無多高的壁壘和門檻。所以我整理出了這篇文章,幫助你們用前端的思惟模型去理解原生應用的開發。但願讀完本文,哪怕是沒有相關背景的前端同窗在面對一個 iOS 和安卓的典型工程時,也能知道「這大概是在幹嗎」,不會受限在項目中 *.js
和 *.dart
的小世界裏,更好地和原生開發同窗協做。前端
下面咱們會依次介紹這幾個部分的內容:java
注意,所謂「原生」的概念是有歧義的。在某些語境下,使用 Swift、Objective-C 和 Java 的移動端開發者會認爲這些應用層開發語言才屬於「原生」,而更「底層」的 C/C++ 則不屬於這一範疇。本文中的「原生」泛指能直接接觸到平臺(操做系統)標準 API 的技術棧,不會分得這麼細。另外在本文語境下,所謂「前端應用」等價於「Web 應用」。node
不少前端同窗可能對原生開發多少有種畏懼感——畢竟那但是會編譯成機器碼的東西啊!但其實 Web 應用與原生應用之間有兩條值得一提的共性,它們能夠加強你的自信心:react
npm install
配出 Webpack 全家桶環境,那麼你其實已經熟悉這種「自動化管理依賴,並經過工具鏈來編譯應用」的工做流了,心智模型的適應成本並不高。固然,原生應用仍然是比 Web 應用更爲「low-level」的,這種差別會在不少地方體現出來:android
node_modules
源碼那樣直接修改你依賴的庫,沒有能夠隨手求值表達式的控制檯,沒有 HMR 熱更新,空指針更可能直接讓應用崩潰……不過,Web 應用也未必就比原生應用更簡單。好比做爲輸入 URL 就能直接訪問的平臺,Web 應用很是關注首屏性能,有許多加速頁面載入的深度優化策略。這反而不是原生應用開發中的關注重點。ios
紙上談兵式的心靈雞湯和預防針到此爲止。相信你們如今最關心的必定是這件事——若是我只有前端開發的背景,怎樣看明白一個 iOS 或安卓應用的代碼是在幹嗎呢?接下來咱們就來解答這個問題。git
有個可能有些使人難過的事實,那就是直到 2020 年,Objective-C 語言仍然是 iOS 開發中的一道坎。尤爲對 Hybrid 應用來講,一旦涉及原生插件開發,或者接入某些第三方 SDK,那麼你仍是很難繞開它。好比在 React Native 中若是想用 Swift 開發 Bridge,仍然要加上 @objc
修飾符,並配置用於混編的頭文件——因此咱們不如干脆直接來搞懂 Objective-C 吧!我的經驗是隻要能看懂這門表面上古怪的語言,iOS 平臺使人畏懼的程度一下就會低不少。github
在我認識的(非狂熱果粉)開發者羣體裏,你們廣泛認爲 Objective-C 是一門看起來十分晦澀的語言。它獨特的方括號語法、排版方式和冗長的 API,都使不少人對其望而卻步。但某種程度上,深受 Smalltalk 影響的它纔是正統「面向對象」今日的遺孤。要理解這種風格,最重要的一點在於接受方括號的「發消息」語義。這時一種很是實用的理解方式,是把方括號語法直接看成換皮的方法調用。舉幾個最簡單的例子:web
// 向 NSDate 類發送 date 消息,得到 now 實例
NSDate* now = [NSDate date];
// 向 now 實例發送 timeIntervalSince1970 消息,得到秒級時間戳
double seconds = [now timeIntervalSince1970];
// 向 now 實例發送 dateByAddingTimeInterval 消息,參數爲 114514
NSDate* later = [now dateByAddingTimeInterval:114514];
複製代碼
它們改寫成等價的 JS 就是這樣的:
let now = NSDate.date()
let seconds = now.timeIntervalSince1970()
let later = now.dateByAddingTimeInterval(114514)
複製代碼
而後對稍微複雜一點的狀況也是同理:
// 另外一種初始化方式,即先發 alloc 消息,再發 init 消息
NSDate* now = [[NSDate alloc] init];
// 初始化一個 NSCalendar 日期實例
NSCalendar* obj = [NSCalendar currentCalendar];
// 給實例發多個參數的消息
// 消息名爲 ordinalityOfUnit:inUnit:forDate:
NSUInteger day = [obj ordinalityOfUnit:NSDayCalendarUnit
inUnit:NSMonthCalendarUnit
forDate:now];
複製代碼
這幾行發送消息的代碼,換成 JS 的方式來理解則大概是這樣的:
let now = NSDate.alloc().init() // [[NSDate alloc] init]
let obj = NSCalendar.currentCalendar()
// 方法名由參數列表拼接組成
let name = "ordinalityOfUnit:inUnit:forDate:"
let day = obj[name](NSDayCalendarUnit, NSMonthCalendarUnit, now)
複製代碼
爲何不直接 new NSDate()
呢?由於 Objective-C 是對 C 語言的擴展,它沒有選擇像 C++ 那樣加入 new
關鍵字,而是設計了 [[NSDate alloc] init]
這樣的消息組合,來完成對象的構造。alloc
至關於 C 語言裏的內存分配函數 malloc
,而 init
則至關於默認的實例構造器。若是須要其餘的構造器,Objective-C 裏的方式是本身動手實現形如 initWithXxx
的方法(即所謂的 initialiser 初始化器)。像這樣的代碼:
// initWithXxx 在 Objective-C 中很是常見
MyWidget* widget = [[MyWidget alloc] initWithStr:@"hello"
width:114
height:514];
複製代碼
就能夠根據上面的規律,近似地這麼轉換:
let name = "initWithStr:width:height:" // 將消息類型理解爲方法名
let initialiser = MyWidget.alloc()[name] // 先 alloc 分配空間
let widget = initialiser("hello", 114, 514) // 再 init 初始化
// 或者這樣更簡單的理解
let widget = new MyWidget("hello", 114, 514)
複製代碼
這裏的 MyWidget
是一個類——做爲面嚮對象語言,Objective-C 裏是有類的。所以現代 JS 中 class 語法對應的「類和實例」經典心智模型,在這裏是徹底可複用的,只是語法較爲非主流而已。和 C++ 相似地,Objective-C 中典型的 class 須要在 .h
頭文件裏暴露出 @inferface
聲明,而後在 .m
文件裏編寫相應的 @implementation
實現。像上面的 MyWidget
就能夠按這種方式來聲明:
// MyWidget.h
@interface MyWidget : NSObject // 繼承 NSObject
@property TypeA a; // 聲明一個屬性,寫在這裏的屬性對外可見
// 初始化器,instancetype 表示返回該類的實例
- (instancetype)initWithA:(TypeA)a
b:(TypeB)b
c:(TypeC)c;
// "-" 開頭的是實例方法
// 須要用 [myWidget doSomething1:x] 形式調用
- (void)doSomething1:(TypeX)x;
// "+" 開頭的是類方法
// 能夠用 [MyWidget doSomething2:y] 形式調用
+ (void)doSomething2:(TypeY)y;
@end
複製代碼
其相應的實現則是這樣的:
// MyWidget.m
#import "MyWidget.h" // import 至關於 include
@implementation MyWidget
@property TypeB b; // 聲明另外一個屬性,寫在這裏的屬性是私有的
- (instancetype)initWithA:(TypeA)a
b:(TypeB)b
c:(TypeC)c {
if(self = [super init]) {
self.a = a;
self.b = b;
// ...
}
}
- (void)doSomething1:(TypeX)x {
// ...
}
+ (void)doSomething2:(TypeY)y {
// ...
}
@end
複製代碼
別被上面這些代碼唬住了,它們其實只至關於寫了這麼一點點 JS 而已:
class MyWidget extends Object {
constructor(a, b, c) {
super()
this.a = a
this.b = b
// ...
}
doSomething1(x) {
// ...
}
static doSomething2(y) {
// ...
}
}
複製代碼
只要能看明白 Objective-C 的這套表層語法,你可能會發現本身一會兒就能讀懂不少東西了。好比下面這段 React Native 中用於定製原生組件的官方示例代碼:
// 初始化出某個支持了 React Bridge 功能的對象
// id 至關於指向任意類型對象的指針,這裏加了個可選的類型提示
// RCT 是 React 的簡寫
id<RCTBridgeDelegate> moduleInitialiser = [[classThatImplementsRCTBridgeDelegate alloc] init];
// 用上面的對象來創建 bridge 實例
RCTBridge *bridge = [[RCTBridge alloc] initWithDelegate:moduleInitialiser launchOptions:nil];
// 用上面的 bridge 實例來創建 React Native 的 root view
RCTRootView *rootView = [[RCTRootView alloc]
initWithBridge:bridge
moduleName:kModuleName
initialProperties:nil];
複製代碼
是否是看起來容易理解不少了呢?
最後,若是你實在不習慣 Objective-C 的代碼排版風格,這裏還有個小貼士:安裝 VSCode 的 clang-format 插件,在項目根目錄中添加一個 .clang-format
文件,其內容只要一行 BasedOnStyle: Chromium
便可。這樣就能用 VSCode 把「千奇百怪的醜」變成「整齊劃一的醜」了——並且其實看久了之後,它仍是蠻有一股混元形意勁的。
上面的基礎語法介紹,只足夠讓你閱讀最基本的 Objective-C 代碼。若是想更好地理解 iOS 原生應用的 UI 邏輯,還須要瞭解其中很是常見的 Delegate 與 Protocol 概念。
什麼是 Delegate 呢?這是 Objective-C 中用於替代繼承的設計。咱們都知道,經典的繼承機制雖然容易理解,但繼承鏈帶來的問題也是很明顯的。好比假設框架提供了某種功能複雜而強大的 UIView 對象,那麼按照 JS 中最樸素的形式,咱們會經過直接繼承這個類的方式來複用它:
class MyView extends UIView {
// 在 view 加載完成時觸發的生命週期鉤子
viewDidLoad() {
// 這裏的 this 上什麼寶貝都能掏出來
}
}
複製代碼
這不只容易製造出層層包裝的臃腫對象,還不易於讓一個類同時扮演多種角色(涉及複雜的多繼承)。相比之下,Objective-C 選擇經過協議(protocol)機制來替代繼承,其基本理念是這樣的:各類框架層對象會把本身可供定製的部分(例如各類回調方法的 API)規定爲一份協議,而後由咱們本身實現出符合協議接口(interface)的對象,把這個自定義的對象做爲代理「嵌入」重型的框架對象。舉例來講,假如咱們想自定義 WebView 的一些行爲,那就不該該直接去繼承 UIWebView 這個類,而是這樣的:
webViewDidStartLoad
這樣的生命週期鉤子。這份協議中的方法既能夠是必選的,也能夠是可選的。delegate
屬性賦值。delegate
成員對象發消息(執行方法調用),完成整個流程。若是你仍是不習慣 Delegate 這個詞,也能夠把它理解爲 Controller。iOS 中 UIView 和 UIViewController 之間的關係,就是典型的代理關係。
要演示這個代理機制,大概只須要這麼幾段 Objective-C 代碼:
// 由框架聲明 UIWebView 代理協議
@protocol UIWebViewDelegate <NSObject>
@optional // 可選方法
- (void)webViewDidStartLoad:(UIWebView*)webView;
// 其餘各類方法
@end
// 由使用者實現符合該協議的類
@interface MyClass <UIWebViewDelegate>
// ...
@end
// 爲已有的框架對象實例設置代理
// 通常還能夠經過 initWithDelegate 直接初始化框架對象
MyClass* delegate = [[MyClass alloc] init];
[webView setDelegate:delegate];
複製代碼
上面的 Objective-C 代碼,大致上能夠理解爲這樣的 JS 模式:
class MyClass implements WebViewDelegate {
// 根據協議實現的方法
webViewDidStartLoad() {
// 再也不存在一應俱全的 this
}
}
// 實例化咱們的輕量級 delegate 對象
let delegate = new MyClass()
myView.delegate = delegate
複製代碼
如今,咱們就能更好地理解上面那段 React Native 的原生組件示例代碼了:
// 實例化出咱們自定義的代理對象
id<RCTBridgeDelegate> moduleInitialiser = [[classThatImplementsRCTBridgeDelegate alloc] init];
// 用代理對象實例化出 React Native 的框架 bridge 對象
RCTBridge *bridge = [[RCTBridge alloc] initWithDelegate:moduleInitialiser launchOptions:nil];
// 用 bridge 實例化出 React Native 的 root view
RCTRootView *rootView = [[RCTRootView alloc]
initWithBridge:bridge
moduleName:kModuleName
initialProperties:nil];
複製代碼
對於代理機制的更多細節,能夠參考 Stack Overflow 上的 How do I create delegates in Objective-C討論,以及蘋果官方的 Delegation 文檔。
理解 Delegate 機制後,相信你們在閱讀 Objective-C 代碼時就不會存在太多障礙了,無非就是數組(NSMutableArray)和 Map(NSDictionary)之類標準數據結構的 API 冗長一點而已。固然,Objective-C 的特性不可能在這麼簡單的篇幅裏覆蓋全,它仍是有不少亮點的。例如它的 ARC 內存管理機制,其實並不輸於現代 C++ 的智能指針;再好比它基於 GCD 的多線程能力,也是很優秀的設計。若是你須要瞭解更多 Objective-C 的語言特性,推薦參考《Objective-C Programming - The Big Nerd Ranch Guide》一書。
在上一節中,咱們已經提到了 UIView 這樣的 UI 對象,不過仍然沒有演示典型的 iOS UI 是如何創建的。這裏有個好消息,那就是經典 iOS 應用的 UI 也遵循 MVC 設計模式,相信每位如今的前端同窗都多少接觸過它的變體(好比把 JSX 或 DOM 模板理解爲 View 層,將 State 或 Store 理解爲 Model 層,將 ViewModel 理解爲 Controller 層)。典型的例子就是經常使用於實現可滾動長列表的 UITableView:
怎樣在屏幕上繪製出這樣的 UI 呢?在最簡單的狀況下,大致上有這麼一些須要知道並控制的事:
didFinishLaunchingWithOptions
,好囉嗦)裏,實例化這個 UITableView 對象。這裏通用的風格是先創建出一個 CGRect 類型的矩形 Frame 對象,再用這個 Frame 去初始化 View,也就是 initWithFrame
。dataSource
數據源對象,這也是一種典型的代理形式。能夠直接讓 AppDelegate 實現 UITableView 須要的協議,而後把它設置爲 UITableView 對象的數據源(代理)。上面的描述對應的實際代碼大體是這樣的,就不翻譯成 JS 了:
// AppDelegate.h
// AppDelegate 默認支持 UIApplicationDelegate 協議
// 這裏再讓它多支持一個 UITableViewDataSource 協議
@interface MyAppDelegate
: UIResponder <UIApplicationDelegate, UITableViewDataSource> {
// 也能夠用這種語法定義成員變量,這裏的 table 屬於 view 層
UITableView* table;
// 用數組存儲列表數據,做爲 model 層
NSMutableArray* data = @[@"foo", @"bar", @"baz"];
}
// AppDelegate.m
// ...
// 在應用初始化完成時觸發的生命週期鉤子
didFinishLaunchingWithOptions:(NSDictionary*)launchOptions {
// 沒有用 Storyboard 拖控件的話,要手動初始化 UIWindow
// 這幾行和 UITableView 自己無關
CGRect windowFrame = [[UIScreen mainScreen] bounds];
UIWindow *theWindow = [[UIWindow alloc] initWithFrame:windowFrame];
[self setWindow:theWindow];
// 先創建 frame 對象,而後用它初始化 UITableView
CGRect tableFrame = CGRectMake(0, 80, 320, 380);
table = [[UITableView alloc] initWithFrame:tableFrame
style:UITableViewStylePlain];
[table setSeparatorStyle:UITableViewCellSeparatorStyleNone];
// 設置 table 的數據源爲 AppDelegate
[table setDataSource:self];
// 把 table 掛載到 window 上
[[self window] addSubview:table];
}
// ...
// UITableViewDataSource 協議規定的方法,用於獲取列表長度
- (NSInteger)tableView:(UITableView*)tableView
numberOfRowsInSection:(NSInteger)section {
// 返回列表數組的長度
return [data count];
}
// UITableViewDataSource 協議規定的方法,用於獲取列表項
- (UITableViewCell*)tableView:(UITableView*)tableView
cellForRowAtIndexPath:(NSIndexPath*)indexPath {
// 爲提升性能,每次請求列表項時應先嚐試複用已有的 cell 實例
UITableViewCell* c = [table dequeueReusableCellWithIdentifier:@"Cell"];
if (!c) {
// 當不存在空餘 cell 實例時,初始化新的 cell 實例
c = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault
reuseIdentifier:@"Cell"];
}
// 將 cell 實例的內容設置爲 data 數組中相應的字符串
// 這裏的 [indexPath row] 對應 UITableView 列表的下標
NSString* item = [data objectAtIndex:[indexPath row]];
[[c textLabel] setText:item];
// 返回 cell 實例供框架渲染
return c;
}
複製代碼
可見,整個 AppDelegate 的用途至關於 Controller 層。上面的 didFinishLaunchingWithOptions
屬於典型的 iOS 應用生命週期鉤子,相似的還有 viewDidLoad
。很明顯,React 中經典的 componentDidMount
/componentWillMount
/shouldComponentUpdate
這些 API,就是借鑑 iOS 平臺的命名風格而設計的。這或許是 Objective-C 對前端社區最大的影響了吧。若是你有其餘 API 命名層面上的疑惑,能夠參考蘋果的 Objective-C 命名風格文檔。
注意,AppDelegate 還能夠實現多種不一樣的協議,從而使單個類能無縫地扮演多種角色,支持與不一樣 UI 對象互動。我的認爲這也是「組合優於繼承」的體現。固然,把全部業務邏輯都放在單個對象上確定是很差的行爲,實際場景中通常會更細粒度地拆分出負責不一樣應用模塊的對象。若是你想把上面純粹創建靜態 UITableView 的代碼發展爲一個 TodoMVC 性質的簡單應用,參見《Objective-C Programming - The Big Nerd Ranch Guide》的 Chapter 27,這裏再也不贅述。
另外在 SwiftUI 以前,iOS 的 UI 開發使用的都是比較傳統的 OO 風格,不支持 JSX 那樣直接聲明式地編寫出組件的層級結構。這樣在創建多個具有複雜嵌套結構的 View 時,很容易出現一大堆相似於手動生成 DOM 對象的麪條代碼。爲此,Xcode 提供了 Interface Builder 這樣拖控件創建 UI 的工具。它能夠生成 XIB 格式的佈局文件,簡化手動編碼的工做量,也支持用 Auto Layout 來便捷地配置 UI 樣式。而在管理多個 ViewController 之間的切換(相似頁面路由跳轉)時,Xcode 也提供了 Storyboard 這樣的可視化工具。不過因爲在開發 Hybrid 框架時每每並不須要拖控件,所以我的並不熟悉上面這些工具,感興趣的同窗能夠本身搜索 iOS 開發教程學習。尤爲是在設計一些時下流行的「拖拽組件生成頁面」的前端 Low Code 平臺時,Xcode 的經典設計或許也能對你們有所啓發。
上面的介紹主要屬於純粹的代碼層面。但對於 iOS 這樣自成體系的開發,幾乎沒有人會使用純粹的文本編輯器來編碼,而是使用 Xcode 這個 IDE。接下來咱們會簡單介紹一些 Xcode 的基礎操做。
咱們以 Flutter 插件來做爲例子。在 Flutter 中,插件(Plugin)能夠用來把平臺特定的能力包裝給 Dart 側使用。每一個 Flutter 插件項目中,都既會包含「做爲庫被使用的」可複用 Dart 代碼,也會包含調用這些庫 API 的示例 Dart 代碼。一樣地,在建立插件時,Flutter 默認既會建立出供複用的 iOS 平臺代碼,也會建立出調用這些代碼的示例 iOS 項目。這個過程只須要一行命令就足夠了:
$ flutter create --template=plugin --platforms=android,ios -i objc hello_world
複製代碼
咱們這裏關心的是插件的 iOS 部分。所以接下來不要去直接 flutter run
,而是手動這麼幹:
$ cd hello_world/example/ios
$ pod install # 須要先安裝好包管理器 CocoaPods
複製代碼
如今打開 hello_world/example/ios
目錄下的 Runner.xcworkspace
文件(注意不是 hello_world/ios
,也不是 Runner.xcodeproj
),就能夠看到 iOS 視角下的 Flutter 工程了。如圖所示:
這裏咱們打開的是名爲 Runner 的 Xcode Workspace。Runner 是由 Flutter 生成的名字,能夠理解成這是用來運行 Flutter 環境的一層殼。Xcode 中的每一個 Workspace 能夠包括多個 Project,在這個例子中就是左側藍色的 Runner Project(即插件的示例應用)和 Pods Project(即做爲 CocoaPods 依賴被安裝進來的插件工程,還有 Flutter 框架)。上圖中字母標記出的位置,則對應一些最主要的 IDE 功能:
cmd
+ 數字切換。cmd
+ R
。.dart
裏。hello_world
子目錄,能夠查看插件實際的 iOS 平臺源碼。cmd
+ shift
+ Y
。上面這些功能主要是用於具體編碼的,還有一個重要的部分是進行項目總體的配置。點擊上面的 E 或 F 便可進入:
這裏能夠看到 PROJECT 和 TARGETS 兩個不一樣的標籤。每一個 Xcode 的 Project 能編譯到多種 Target。對標準的 iOS App 來講,默認 Target 就是整個應用的 Bundle。在這個界面下,能夠配置各類編譯選項,好比庫和框架的搜索路徑、自定義宏參數、環境變量等。iOS 工程中 .h
和 .m
源碼的編譯方式,基本與經典的 C/C++ 項目一致。能夠把這裏看成一個可視化的 Webpack 配置界面,有須要時在這裏修改具體的配置。
在這個項目裏,還能夠用 CocoaPods 安裝第三方依賴。CocoaPods 和 NPM 的用法很像,安裝後的依賴會出如今 Pods Project 下。最後還有個地方值得一提,那就是 Xcode 中的目錄結構是虛擬的,若是打開 Finder 發現文件路徑對不上的話不要以爲奇怪,以 IDE 中最終展現的爲準便可。
目前介紹的這些內容,應該足夠幫助你們更好地理解 React Native 和 Flutter 插件的 iOS 部分了。限於篇幅,本文中的 iOS 部分也就到此爲止。整體來看,雖然 Objective-C 和 Xcode 對前端同窗們可能較爲陌生,但它們反映了蘋果在 UI 開發範式上多年來的積累和探索,是值得尊重的。另外 iOS 對於混編 C++ 的支持很不錯,若是你但願在移動端嘗試一些經典的 C++ 庫,它也是比較方便的選擇。
和 iOS 部分相比,本文對安卓的介紹會相對少不少——Java 與 TypeScript(至少在表層語法上)看起來很接近,其基於繼承的 OO 機制也和 ES2015 後的 JavaScript 基本一致。所以這裏無需花費精力像 Objective-C 那樣去從頭介紹基本的語言語法和 OO 機制,節約了不少篇幅。
安卓大量應用了 XML 來管理可視化拖拽控件生成的 UI,以及不少靜態配置和資源。但和 iOS 中重度依賴 Interface Builder 的 XIB 不一樣,安卓的 XML 是更爲語義化的,對手工編輯更爲友好(iOS 在 XIB 以前甚至還使用的是二進制的 NIB,它對 Git 工做流來講簡直是災難)。下面咱們給出一段安卓應用中典型的 XML:
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" package="com.example.helloworld">
<application android:allowBackup="true" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.HelloWorld">
<activity android:name=".MainActivity" android:label="@string/app_name" android:theme="@style/Theme.HelloWorld.NoActionBar">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
複製代碼
先無論這段 XML 的實際用途,你以爲它和前端常見的 HTML 在語法上有什麼不一樣呢?歸納說來大體有這麼幾點:
xmlns
命名空間(namespace)屬性。這在 HTML5 裏是可選(即支持但沒多少人用)的。這裏形如 xmlns:android="http://schemas.android.com/apk/res/android"
的屬性,就表示這份 XML 內部的android
屬性所對應的 URI(即惟一的資源標識符,但不是有效的 URL 地址)。用這種方式,還能夠把咱們本身定義出的 TextView 和安卓默認的 TextView 區分開。".MainActivity"
的語法來指向 Java 中的 class,這裏指向的就是後面會提到的 MainActivity。"@string/app_name"
的語法來互相連接,像這裏這個 app_name
的值就會指向另外一份 XML 文件中的 <string name="app_name">HelloWorld</string>
標籤,從而得到其中的 HelloWorld
常量。上面的 XML 就是所謂的 Manifest,這是安卓應用頂層的配置。除此以外,安卓中標準的 UI 元素也使用 XML 形式來定義,例如 TextView 和 Button 就是下面這樣的,初看起來很像 React:
<TextView android:id="@+id/textview_first" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/hello_first_fragment" app:layout_constraintBottom_toTopOf="@id/button_first" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" />
<Button android:id="@+id/button_first" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/next" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/textview_first" />
複製代碼
和交由框架讀取的 Manifest 不一樣,這些表示 UI 的 XML 須要在業務代碼中被用到。這時要怎麼在 Java 中找到對應的 XML 節點呢?和樸素的 DOM 很是相似地,這是經過 XML 中的 id
屬性來實現的。像上面 XML 中形如 android:id="@+id/textview_first"
這樣的字段,就定義了 textview_first
這個 ID。這裏加號表示這是咱們新增的 ID,而非平臺已有的。Button 元素能夠經過 ID 來語義化地與 TextView 元素互相引用,這對錶達佈局約束頗有幫助。而且這些 XML 元素也易於在 Java 代碼中訪問到:
// 某個類中約定的返回自定義 view 的方法
@Override
public View onCreateView( LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState ) {
// 用 id 從 XML 中 inflate 出自定義的 view
return inflater.inflate(R.layout.my_fragment, container, false);
}
複製代碼
在上面的代碼中,靜態的 XML 元素被反序列化(即 inflate)成了運行時的 View 對象。而傳入 inflate
方法的 R 對象,則是包含了應用中所有資源定義的對象實例。若是你的安卓應用包名是 com.example.hello
,那麼應用中全部的資源就會被自動生成到 com.example.hello.R
這個類下面。順帶多說一句,Android Studio 在打開 XML 文件時默認會展現可視化的 Design 視圖,想看 XML 結構的話切換成 Code 視圖就能夠了。
只要能明白安卓上 Java 和 XML 之間這種常見的綁定關係,就能夠開始瞭解安卓平臺上的 UI 抽象,進而理解典型安卓應用的結構了。
這裏的例子是個由 Android Studio 生成的默認應用,它能夠點擊紫色按鈕切換白色區域內的文字:
對於這個「點擊按鈕切換頁面」的簡單安卓應用,有這麼幾個最基本的概念值得了解:
AndroidManifest.xml
裏經過 <activity>
標籤,靜態地聲明本身有多少個 Activity。每一個 Activity 都有本身的 XML 佈局(layout),它們位於 res/layout
目錄下。res/layout
。AndroidManifest.xml
文件中,<activity>
元素內就有靜態寫好的 <intent-filter>
元素,其中指定了 Activity 要監聽名爲 MAIN
的事件。 不過這個例子簡單到沒有涉及多個 Activity 之間的切換,所以這裏就不詳細展開了。如這張 Android Developers 文檔中的附圖所示,你也能夠直接在 Activity 中構建 UI(管理各類 View 對象)。但推薦的作法是用 Activity 管理那些應用中長期存在的靜態性、全局性 UI,用 Fragment 管理那些較爲動態的部分。
以剛纔的「按鈕點擊切換 UI」這個需求爲例,下面作一個簡要的介紹,把值得關注的部分代碼串起來。首先天然是做爲應用入口的 MainActivity 了:
// 這個名字略繁冗的 AppCompactActivity 是用來保障前向兼容性的
public class MainActivity extends AppCompatActivity {
// 應用初始化時的生命週期鉤子
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// 把 layout/activity_main.xml 加載爲佈局
// 這裏會自動 inflate,無需使用 inflater
setContentView(R.layout.activity_main);
// ...
}
// ...
}
複製代碼
這段代碼實例化了 activity_main.xml
,這份 XML 中有 Toolbar(工具欄)、FloatingActionButton(底部按鈕)等表示靜態 UI 的 XML 元素,這些都很容易理解。而頁面中支持動態切換的部分,則是用一行 <include layout="@layout/content_main" />
所引入的另外一份 XML,其內容就是咱們要爲其編寫業務邏輯的 Fragment:
<!-- content_main.xml -->
<fragment android:id="@+id/nav_host_fragment" android:name="androidx.navigation.fragment.NavHostFragment" android:layout_width="0dp" android:layout_height="0dp" app:defaultNavHost="true" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintRight_toRightOf="parent" app:layout_constraintTop_toTopOf="parent" app:navGraph="@navigation/nav_graph" />
複製代碼
這個 Fragment 有些像路由組件的容器,它的 app:navGraph
屬性指向了一份保存路由的 XML,其中進一步經過 ID 指向了咱們想來回切換的兩個 Fragment:
<!-- nav_graph.xml -->
<navigation xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:id="@+id/nav_graph" app:startDestination="@id/FirstFragment">
<!-- 第一個 fragment -->
<fragment android:id="@+id/FirstFragment" android:name="com.example.helloworld.FirstFragment" android:label="@string/first_fragment_label" tools:layout="@layout/fragment_first">
<!-- 顯式聲明它的路由目標是第二個 fragment -->
<action android:id="@+id/action_FirstFragment_to_SecondFragment" app:destination="@id/SecondFragment" />
</fragment>
<!-- 第二個 fragment -->
<fragment android:id="@+id/SecondFragment" android:name="com.example.helloworld.SecondFragment" android:label="@string/second_fragment_label" tools:layout="@layout/fragment_second">
<!-- 顯式聲明它的路由目標是第一個 fragment -->
<action android:id="@+id/action_SecondFragment_to_FirstFragment" app:destination="@id/FirstFragment" />
</fragment>
</navigation>
複製代碼
上面這兩個 Fragment 的 tools:layout
屬性則能夠進一步跳轉,分別指向兩份用來放置實際 UI 的 XML。這裏每份表達佈局的 XML 裏都放着一個 TextView 和一個 Button,像這樣:
<!-- fragment_first.xml -->
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" tools:context=".FirstFragment">
<TextView android:id="@+id/textview_first" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/hello_first_fragment" app:layout_constraintBottom_toTopOf="@id/button_first" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" />
<Button android:id="@+id/button_first" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/next" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/textview_first" />
</androidx.constraintlayout.widget.ConstraintLayout>
複製代碼
到這裏,咱們就已經看到了 UI 是如何被拆分爲 XML 的了。那麼該如何執行 Fragment 切換的邏輯呢?這隻須要再加一點 Java 代碼就能夠了:
public class FirstFragment extends Fragment {
@Override
public View onCreateView( LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState ) {
// 從 layout/fragment_first.xml 中 inflate 出 view
return inflater.inflate(R.layout.fragment_first, container, false);
}
public void onViewCreated(@NonNull View view, Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
// 爲 id 爲 button_first 的元素註冊點擊事件回調
view.findViewById(R.id.button_first).setOnClickListener(new View.OnClickListener() {
// 傳統的 Java 不支持函數一等公民,須要把回調函數做爲實例方法來傳遞
@Override
public void onClick(View view) {
// 查找匹配這個 Fragment 的路由項,執行相應的跳轉 action
NavHostFragment.findNavController(FirstFragment.this)
.navigate(R.id.action_FirstFragment_to_SecondFragment);
}
});
}
}
複製代碼
這樣咱們就走通了整個動態切換 Fragment 的流程,可見這裏很大的一部分工做都是在處理 XML。不過這裏的 XML 並不支持 {{foo}}
這樣的表達式,不能把它和 JSX 或 Vue 的模板簡單地等同起來。
最後對於 Android Studio 這個 IDE,我的以爲它除了在初始化時須要處理一些網絡問題來下載安卓 SDK 和 AVD 虛擬機等以外,並無什麼須要特殊關照的地方,經常使用功能幾乎都在 IDE 界面周圍的那一圈寫了出來,你們直接生成一個模板項目點進去玩就好了。這裏放一些最基礎的快捷鍵:
cmd
+ 1
開閉左側項目窗口cmd
+ 3
開閉底部查找窗口cmd
+ 5
開閉底部調試窗口cmd
+ 6
開閉底部日誌窗口ctrl
+ R
簡單從新執行應用ctrl
+ D
帶調試器執行應用基於 Android Studio,能夠比較容易地演示 Hybrid 技術棧是如何接入原平生臺的。和 Xcode 作了一個使人困擾的黑盒子 GUI 來配置編譯參數不一樣,安卓項目的構建使用的是 Gradle 這個腳本化的 DSL(詳見 Configure your build)。這裏的例子是個嵌入了 Flutter 的安卓應用:
在上圖中點擊 LAUNCH FLUTTER ACTIVITY,就會切換到 Flutter 的界面。這是如何作到的呢?
一個安卓項目(Project)能夠由多個 Module 組成。Module 既能夠是標準的應用(app module),也能夠是跨項目複用的庫(library module)。這裏的庫又分爲純粹的 Java Library 和安卓專用的 Android Library 兩類,後者能夠包含各類靜態資源和 Manifest 配置(支持複用 Manifest,就至關於支持直接複用一系列開箱即用的 Activity),並打包爲 AAR 格式。Flutter 就能夠經過 Android Library 的形式,嵌入到已有的安卓應用之中。換句話說,這個安卓應用依賴了一個 Flutter Module。
這個 Flutter Module 是怎麼生成的呢?把 Flutter 項目編譯爲 AAR 格式便可:
$ cd my_flutter_module_project
$ flutter build aar
複製代碼
咱們這樣編譯出的 Flutter AAR 庫,能夠按照與安卓工做流匹配的方式來複用。在安卓中,引入第三方依賴的方式和配置 package.json
也很接近,像這樣:
// app/build.gradle
// 指定安卓應用編譯 SDK 的默認配置,與 Flutter 無關
android {
compileSdkVersion 28
defaultConfig {
applicationId "dev.flutter.example.androidusingprebuiltmodule"
minSdkVersion 19
targetSdkVersion 28
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
multiDexEnabled true
}
buildTypes {
release {
minifyEnabled false
}
}
compileOptions {
sourceCompatibility 1.8
targetCompatibility 1.8
}
}
// 依賴倉庫配置
repositories {
maven {
// 運行 `flutter build aar` 後生成的本地依賴
// 其中包含被接入的 Flutter 項目編譯後的產物
url '../../flutter_module/build/host/outputs/repo'
}
maven {
// 包含 Flutter 在安卓端依賴的運行時
url 'https://storage.googleapis.com/download.flutter.io'
}
}
// 指定具體的依賴項
dependencies {
// 被編譯到 AAR 格式的發佈模式 Flutter 項目
releaseImplementation ('dev.flutter.example.flutter_module:flutter_release:1.0@aar') {
transitive = true
}
// 被編譯到 AAR 格式的調試模式 Flutter 項目
debugImplementation ('dev.flutter.example.flutter_module:flutter_debug:1.0@aar') {
transitive = true
}
// 一些其餘的標準安卓項目依賴
implementation 'androidx.multidex:multidex:2.0.1'
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation 'androidx.appcompat:appcompat:1.1.0'
implementation 'androidx.core:core-ktx:1.1.0'
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
// ...
}
複製代碼
配置好依賴後,只要在安卓項目的按鈕點擊回調中發送 Intent,就能夠啓動 Flutter 的 Activity 了。Flutter 還能以 Fragment 的形式更靈活地接入安卓項目。具體詳見 Add Flutter to Existing App 系列文檔,以及官方的 Add-to-App Samples。另外這裏配置的只是 Java 依賴,若是想在安卓平臺上嘗試 JS 引擎等 C++ 項目,還須要配置 NDK 並熟悉 JNI 這套 Java 與 C++ 之間的橋接 API。
以上很是簡略地介紹了「前端同窗可能較爲感興趣的」安卓平臺開發相關內容。做爲總結,我的以爲安卓是個相對更爲接近 Web 工做流和習慣的平臺,須要特別強調的特殊設計相對較少,更易於前端同窗對移動端開發作一些學習性的入門嘗試。
最後,這裏概述性地科普一下前端技術棧(包括 JavaScript 和更廣義上的 Dart)與原生技術棧之間的交互。
咱們經常使用 Bridge(也能夠理解成 FFI)來統稱那些鏈接不一樣語言環境的接口。對 Hybrid 應用來講,有不少你們耳熟能詳的 Bridge,如 iOS WebView 的 JS Bridge、React Native 的 JS Bridge、Node.js 的 N-API,以及 Flutter 的 Platform Channel 等等。在我的理解中,它們大致上能夠分爲兩種,即異步的 Bridge 和同步的 Bridge。
所謂異步和同步,主要指的是 Bridge 在 Hybrid 業務邏輯一側的使用方式,像這樣:
const a = await someNativeMethod() // 異步 Bridge
const b = someNativeMethod() // 同步 Bridge
複製代碼
這種差別初看起來只是 API 設計層面的一點區別,但背後的實現原理卻可能有很是大的不一樣。雖然「異步」聽起來彷佛更爲複雜,但這類 Bridge 的實現成本通常更低,也通常是常見 Hybrid 方案默認會提供的。它們的常見特色是這樣的:
若是隻是想在某個原生按鈕被按下時通知 JS,這種 Bridge 是很合適的。但若是你但願在每幀高頻地在 JS 環境操做一些原生對象,那麼須要考慮同步的 Bridge。
同步的 Bridge 通常會更接近語言引擎暴露出的標準 API,例如 N-API 和 Dart FFI。它經常用來爲語言實現出各平臺上的標準庫。好比 JS 的 ECMAScript 規範中就沒有 DOM 的概念,像 document.getElementById
這樣的 API,就能夠認爲是經過同步的 Bridge 來定義的。這方面作得最好的是 Node.js 的 N-API。簡單說來,若是想用 C++ 給 JS 運行時擴展出一個樸素的原生 Print 方法,只要幾行就能夠實現出其本體了:
#include <napi.h>
#include <string>
#include <cstdio>
// 原生函數均接受 JS 的調用者對象,返回某種 JS 中的 Value
Napi::Value Print(const Napi::CallbackInfo& info) {
// 把 JS 傳入的第一個參數導出到 std::string
auto x = info[0].As<Napi::String>().Utf8Value();
printf("%s\n", x.c_str()); // 向譚浩強老師致敬
return info.Env().Undefined(); // 返回 undefined
}
複製代碼
而後只要幾行膠水,就能把這個 Print
函數包裝成 Node.js 中的 C++ 原生模塊:
Napi::Object InitAll(Napi::Env env, Napi::Object exports) {
exports.Set("Print", Napi::Function::New(env, Print));
// 其餘原生方法也相似,照貓畫虎便可
exports.Set("Hello", Napi::Function::New(env, Hello));
exports.Set("World", Napi::Function::New(env, World));
return exports;
}
// 要導出整個模塊,在整個 C++ 文件最後加一行便可
NODE_API_MODULE(NODE_GYP_MODULE_NAME, InitAll)
複製代碼
最後只要編譯出這個有效代碼不過十來行的 C++ 文件就行:
const { Print } = require('./build/Release/demo.node');
Print('hello world');
複製代碼
這種 Bridge 最大的優點是不須要異步通訊的開銷,和原生環境有很好的兼容性:
// info[0] 是 JS 中傳來的第一個函數參數
// 能夠把任意的原生資源對象直接掛到這個 JS 對象上
Napi::External a = info[0].As<Napi::External<MyResource>>();
複製代碼
這種場景下,JS 的 GC 能夠用來管理原生對象的生命週期,好比在 JS 對象垃圾回收時自動釋放掉綁定在它上面的 C++ 對象,是很方便的。不過要注意,各類帶 GC 語言的對象生命週期,通常都是引擎自行管理的。像在 JS 和 Java 這樣兩門帶 GC 的語言之間,就很難作到這樣的效果,仍是須要回退到異步深拷貝數據的 Bridge 形式。
另外在這方面,Dart VM 的 FFI 作得比 Node 更進一步,能夠在 Dart 側直接打開動態庫,執行裏面的函數(符號)。好比我如今有個 C 語言的 Hello World 函數:
void hello_world() {
printf("Hello World\n");
}
複製代碼
它在被編譯成動態庫(macOS 上的 .dylib
,或者 Windows 上的 .dll
)後,就能夠在 Dart 裏直接這麼調用:
// 在 Dart 側創建 C 函數的簽名
typedef hello_world_func = ffi.Void Function();
// C 函數會被包裝成 Dart 函數,爲這個 Dart 函數類型定義一個別名
typedef HelloWorld = void Function();
// 打開動態庫
final dylib = ffi.DynamicLibrary.open('hello_world.dylib');
// 從動態庫中查找名爲 'hello_world' 的 C 函數
// hello_world_func 對應 C 的函數簽名
// 返回的 Dart 原生函數類型爲 HelloWorld
final HelloWorld hello = dylib
.lookup<ffi.NativeFunction<hello_world_func>>('hello_world')
.asFunction();
// 調用這個直通 C 的 Dart 函數
hello();
複製代碼
Dart FFI 提供的這種能力在 JS 社區也有實現,參見 node-ffi-napi。
簡單總結:異步 Bridge 存在通訊的性能瓶頸,而同步 Bridge 雖然強大,但開發接入的成本相對較高。若是你在開發 Hybrid 應用,那麼請注意挑選適合本身應用場景的 Bridge 方案。固然若是你只是單純對跨語言技術棧感興趣但缺少相關經驗,那麼不妨利用 Node.js 的 node-addon-api 和 QuickJS 進行一些嘗試。它們都能輕鬆地讓 JavaScript 接觸到很是 low-level 的能力,擴展前端技術棧的邊界範圍。
本文以 iOS 和安卓平臺爲例,介紹了前端背景的同窗在工做中接觸到原生開發時,可能遇到的主要概念性問題。但其實無論在哪一個平臺,GUI 背後的原理都是很是共通的。所以相比拘泥於(甚至神化)某種庫、框架、語言、平臺與編程範式,這裏更建議你們多嘗試看看「外面的世界」,多關注一些通用的基礎性工程知識,例如該如何調試、構建、渲染等。在把更多基礎知識融會貫通地串在一塊兒後,相信你們必定能看到更廣闊的風景——我仍是很看好 Web-like 跨平臺技術棧的將來,但這個將來須要咱們本身去創造。