Hybrid App: 瞭解JavaScript如何與Native實現混合開發
一、簡介
Hybrid Development混合開發是目前移動端開發異常火熱的新興技術,它能夠實現跨平臺開發,極大地節約了人力和資源成本。跨平臺開發催生了很多新的開源框架,就目前而言,在混合開發中比較流行的有FaceBook開源React Native,有Goggle開源的Flutter。React Native實現的是通過下發JS指令碼的方式達到JS與Native互動。Flutter實現的則是通過採用現代響應式框架來構建UI,Flutter與ReactiveCocoa框架配合使用最佳。當然開發者也可以在Native中內嵌WebView的方式(WebKit)實現混合開發。雖然方式不同,但目的相同,都是跨平臺,殊途同歸吧。對跨平臺有了粗略的瞭解後,再來看看iOS系統中對JS與Native是如何互動的,其實,系統是給開發者提供了一個極其強大的框架來實現這個功能的,即JavaScriptCore框架。這個框架通過定義JSValue值物件和宣告JSExport協議作為橋樑完成Native與JS的通訊。JS雖然是單執行緒語言,但是iOS是支援多執行緒執行任務的,開發者可以在非同步情況下執行任意一個環境的JavaScript程式碼。大概結構圖如下:
二、分析
參考這上圖,可以看出JavaScriptCore框架結構還是很清晰的,JavaScriptCore中有那麼幾個核心的類在開發者是很常用的,需要弄懂它們代表的意思。
三、API
知道了這幾個核心類的概念已經對這個框架有了個基本的認識,具體的API如何使用,我們可以選擇性點選去深入研究一下。只有對它們的屬性和方法都瞭如指掌,開發起來才能得心應手,手到擒來。哎呀媽,不廢話了。。。例如JSContext和JSValue開發中必用的類,額外的可能還會用JSManagerValue,如下:
JSContetxt類:
//初始化,可以選擇對應的虛擬機器 - (instancetype)init; - (instancetype)initWithVirtualMachine:(JSVirtualMachine *)virtualMachine; //執行js程式碼,返回js值物件 - (JSValue *)evaluateScript:(NSString *)script; - (JSValue *)evaluateScript:(NSString *)script withSourceURL:(NSURL *)sourceURL; //獲取當前的js上下文 + (JSContext *)currentContext; //獲取當前的js執行函式,返回js值物件 + (JSValue *)currentCallee; //獲取當前的js函式中this指向的物件,返回js值物件 + (JSValue *)currentThis; //獲取當前的js函式中的所有引數 + (NSArray *)currentArguments; //js的全域性物件 @property (readonly, strong) JSValue *globalObject; //js執行的異常資料 @property (strong) JSValue *exception; @property (copy) void(^exceptionHandler)(JSContext *context, JSValue *exception); //js執行的虛擬機器 @property (readonly, strong) JSVirtualMachine *virtualMachine; //js上下文名稱 @property (copy) NSString *name; //分類 @interface JSContext (SubscriptSupport) //獲取和設定屬性為js全域性物件 - (JSValue *)objectForKeyedSubscript:(id)key; - (void)setObject:(id)object forKeyedSubscript:(NSObject <NSCopying> *)key; @end //分類(C函式風格) @interface JSContext (JSContextRefSupport) //獲取和設定全域性上下文 + (JSContext *)contextWithJSGlobalContextRef:(JSGlobalContextRef)jsGlobalContextRef; @property (readonly) JSGlobalContextRef JSGlobalContextRef; @end
JSValue類:
//js上下文 @property (readonly, strong) JSContext *context; //使用OC資料初始化js值物件,建立有值的JSValue + (JSValue *)valueWithObject:(id)value inContext:(JSContext *)context; + (JSValue *)valueWithBool:(BOOL)value inContext:(JSContext *)context; + (JSValue *)valueWithDouble:(double)value inContext:(JSContext *)context; + (JSValue *)valueWithInt32:(int32_t)value inContext:(JSContext *)context; + (JSValue *)valueWithUInt32:(uint32_t)value inContext:(JSContext *)context; + (JSValue *)valueWithPoint:(CGPoint)point inContext:(JSContext *)context; + (JSValue *)valueWithRange:(NSRange)range inContext:(JSContext *)context; + (JSValue *)valueWithRect:(CGRect)rect inContext:(JSContext *)context; + (JSValue *)valueWithSize:(CGSize)size inContext:(JSContext *)context; //使用OC資料初始化js值物件,建立空的JSValue + (JSValue *)valueWithNewObjectInContext:(JSContext *)context; + (JSValue *)valueWithNewArrayInContext:(JSContext *)context; + (JSValue *)valueWithNewRegularExpressionFromPattern:(NSString *)pattern flags:(NSString *)flags inContext:(JSContext *)context; + (JSValue *)valueWithNewErrorFromMessage:(NSString *)message inContext:(JSContext *)context; + (JSValue *)valueWithNewPromiseInContext:(JSContext *)context fromExecutor:(void (^)(JSValue *resolve, JSValue *reject))callback; + (JSValue *)valueWithNewPromiseResolvedWithResult:(id)result inContext:(JSContext *)context; + (JSValue *)valueWithNewPromiseRejectedWithReason:(id)reason inContext:(JSContext *)context; + (JSValue *)valueWithNewSymbolFromDescription:(NSString *)description inContext:(JSContext *)context; + (JSValue *)valueWithNullInContext:(JSContext *)context; + (JSValue *)valueWithUndefinedInContext:(JSContext *)context; //js資料轉OC資料 - (id)toObject; - (id)toObjectOfClass:(Class)expectedClass; - (BOOL)toBool; - (double)toDouble; - (int32_t)toInt32; - (uint32_t)toUInt32; - (NSNumber *)toNumber; - (NSString *)toString; - (NSDate *)toDate; - (NSArray *)toArray; - (NSDictionary *)toDictionary; - (CGPoint)toPoint; - (NSRange)toRange; - (CGRect)toRect; - (CGSize)toSize; //js值物件判斷 @property (readonly) BOOL isUndefined; @property (readonly) BOOL isNull; @property (readonly) BOOL isBoolean; @property (readonly) BOOL isNumber; @property (readonly) BOOL isString; @property (readonly) BOOL isObject; @property (readonly) BOOL isArray; @property (readonly) BOOL isDate; @property (readonly) BOOL isSymbol; - (BOOL)isEqualToObject:(id)value; - (BOOL)isEqualWithTypeCoercionToObject:(id)value; - (BOOL)isInstanceOf:(id)value; //js呼叫函式 - (JSValue *)callWithArguments:(NSArray *)arguments; - (JSValue *)constructWithArguments:(NSArray *)arguments; - (JSValue *)invokeMethod:(NSString *)method withArguments:(NSArray *)arguments; //js屬性設定 - (JSValue *)valueForProperty:(JSValueProperty)property; - (void)setValue:(id)value forProperty:(JSValueProperty)property; - (BOOL)deleteProperty:(JSValueProperty)property; - (BOOL)hasProperty:(JSValueProperty)property; - (void)defineProperty:(JSValueProperty)property descriptor:(id)descriptor; - (JSValue *)valueAtIndex:(NSUInteger)index; - (void)setValue:(id)value atIndex:(NSUInteger)index; - (JSValue *)objectForKeyedSubscript:(id)key; - (JSValue *)objectAtIndexedSubscript:(NSUInteger)index; - (void)setObject:(id)object forKeyedSubscript:(id)key; - (void)setObject:(id)object atIndexedSubscript:(NSUInteger)index; + (JSValue *)valueWithJSValueRef:(JSValueRef)value inContext:(JSContext *)context; //OC與JS型別對應關係 Objective-C type | JavaScript type ---------------------+--------------------- nil | undefined NSNull | null NSString | string NSNumber | number, boolean NSDictionary | Object object NSArray | Array object NSDate | Date object NSBlock (1) | Function object (1) id (2) | Wrapper object (2) Class (3) | Constructor object (3) ---------------------+---------------------
JSManagerValue類:
//對JSValue進行一層包裝,對記憶體進行有效的管理,防止提前或者過度釋放 + (JSManagedValue *)managedValueWithValue:(JSValue *)value; + (JSManagedValue *)managedValueWithValue:(JSValue *)value andOwner: - (instancetype)initWithValue:(JSValue *)value; @property (readonly, strong) JSValue *value;
四、案例
[1] 首先開啟Safari瀏覽器的web檢查器,會用來檢視js執行的效果 ,控制檯列印
[2] 匯入JavaScriptCore框架
[3] 匯入標頭檔案開始測試,Native呼叫JS
[3-1] 呼叫無引數的JS函式
native.js
-(void)nativeCallJs { //方式一 //從js檔案獲取js程式碼 NSString *path = [[NSBundle mainBundle] pathForResource:@"native" ofType:@"js"]; NSData *jsData = [NSData dataWithContentsOfFile:path]; NSString *script = [[NSString alloc] initWithData:jsData encoding:NSUTF8StringEncoding]; //執行js程式碼 [self.jsContext evaluateScript:script]; }
-(void)nativeCallJs { //方式二 //js程式碼寫在端上 NSString *script = @"\ (function(){ \ console.log(\"native call js ------- Wellcome Native\");\ })();"; //執行js程式碼 [self.jsContext evaluateScript:script]; }
- (void)viewDidLoad { [super viewDidLoad]; //js上下文 self.jsContext = [[JSContext alloc] init]; //native呼叫js [self nativeCallJs]; }
[3-2] 呼叫有引數的JS函式
native.js
-(void)nativeCallJsWithArguments:(NSString *)argument { //方式一 //從js檔案獲取js程式碼 NSString *path = [[NSBundle mainBundle] pathForResource:@"native" ofType:@"js"]; NSData *jsData = [NSData dataWithContentsOfFile:path]; NSString *jsString = [[NSString alloc] initWithData:jsData encoding:NSUTF8StringEncoding]; //拼接js引數 NSString *script = [NSString stringWithFormat:jsString,argument]; //執行js程式碼 [self.jsContext evaluateScript:script]; }
-(void)nativeCallJsWithArguments:(NSString *)argument { //方式二 //js程式碼寫在端上 NSString *jsString = @"\ function receive(argument) { \ console.log(\"native call js ------- Wellcome \"+argument);\ };\ receive('%@')"; //拼接js引數 NSString *script = [NSString stringWithFormat:jsString,argument]; //執行js程式碼 [self.jsContext evaluateScript:script]; }
- (void)viewDidLoad { [super viewDidLoad]; //js上下文 self.jsContext = [[JSContext alloc] init]; //native呼叫js [self nativeCallJsWithArguments:@"我的老哥"]; }
[4] 匯入標頭檔案開始測試,JS呼叫Native
注意:呼叫包括無引數和有引數的OC方法,這裡使用程式碼塊Block為例
-(void)jsCallNative { //定義無引數block void (^Block1)(void) = ^(){ NSLog(@"js call native ------- hello JavaScript"); }; //定義有引數block void (^Block2)(NSString *) = ^(NSString *argument){ NSLog(@"js call native ------- hello JavaScript----Wellcome %@",argument); }; //設定block為JSContext全域性物件的屬性,然後可以在safari控制檯執行函式oc_block()輸出列印; [self.jsContext setObject:Block1 forKeyedSubscript:@"oc_block1"]; [self.jsContext setObject:Block2 forKeyedSubscript:@"oc_block2"]; }
- (void)viewDidLoad { [super viewDidLoad]; //js上下文 self.jsContext = [[JSContext alloc] init]; //js呼叫native [self jsCallNative]; }
[5]匯入標頭檔案開始測試, OC和JS物件的對映
//OC與JS資料傳遞的資料就是JSValue值物件,儲存在JS的全域性物件中 //存和取的過程 [self.jsContext setObject:(id) forKeyedSubscript:(NSObject<NSCopying> *)]; [self.jsContext objectForKeyedSubscript:(id)]
[5-1] 系統提供的OC資料型別,不用特殊儲存,可以直接存取
//系統提供的OC資料型別,不用特殊儲存,可以直接存取 [self.jsContext setObject:@"mac" forKeyedSubscript:@"os"]; JSValue *osValue = [self.jsContext objectForKeyedSubscript:@"os"]; NSString *osName = [osValue toString]; NSLog(@"-----osName = %@-----",osName);
2019-11-12 14:58:17.471840+0800 混合開發[10499:365654] -----osName = mac-----
[5-2] 特殊的OC型別,如自定義物件,則必須遵守JSExport協議,JS才能拿到自定義物件的所有屬性和方法
#import <UIKit/UIKit.h> #import <JavaScriptCore/JavaScriptCore.h> //遵守JSExport協議,使得JS在上下文中可以獲取到OC中定義的屬性和方法 @protocol PersonProtocol <JSExport> @property (nonatomic, copy) NSString *name; @property (nonatomic, assign) int age; @property (nonatomic, assign) int grade; @property (nonatomic, assign) float score; -(void)description; @end @interface Person : NSObject<PersonProtocol> @property (nonatomic, copy) NSString *name; @property (nonatomic, assign) int age; @property (nonatomic, assign) int grade; @property (nonatomic, assign) float score; -(void)description; @end #import "Person.h" @implementation Person -(void)description { NSLog(@"姓名:name = %@",self.name); NSLog(@"年齡:age = %d",self.age); NSLog(@"年級:grade = %d",self.grade); NSLog(@"分數:score = %.1f",self.score); } @end
//特殊的OC型別,自定義物件,則必須遵守JSExport協議,JS才能拿到自定義物件的所有屬性和方法 Person *person = [[Person alloc] init]; person.name = @"張三"; person.age = 20; person.grade = 5; person.score = 98; [self.jsContext setObject:person forKeyedSubscript:@"personEntity"]; JSValue *personValue = [self.jsContext objectForKeyedSubscript:@"personEntity"]; //personEntity為OC在JS的物件形式 Person *xyq_person = (Person *)[personValue toObject]; [xyq_person description];
2019-11-12 14:58:17.472563+0800 混合開發[10499:365654] 姓名:name = 張三 2019-11-12 14:58:17.472709+0800 混合開發[10499:365654] 年齡:age = 20 2019-11-12 14:58:17.472810+0800 混合開發[10499:365654] 年級:grade = 5 2019-11-12 14:58:17.472889+0800 混合開發[10499:365654] 分數:score = 98.0
五、實踐
到現在為止,相信我們對JS和Native的互動原理有了自己的理解。在案例中使用了js檔案下發和解析的方式實現了Native執行JS程式碼,這個正是Facebook開源的React Native的設計思路。React Native支援跨平臺,通過一套js檔案就可以在Andriod和iOS上完成Native的介面渲染。現在我們通過一個小測試來模擬Hybrid App的構建原理,通過按鈕點選切換控制器檢視的背景色。
(1) 建立JavaScript指令碼,在指令碼中建立Native需要的任意UI控制元件存到陣列,作為函式的返回值
UIKit.js
//定義一個自呼叫函式,JavaScript指令碼載入完成立即執行 (function(){ return renderUI(); })(); /* JavaScript指令碼 定義一個Label類 * rect:尺寸 text:文字 color:顏色 */ function Label(rect,text,fontSize,textColor,textAlignment,bgColor){ this.rect = rect; this.text = text; this.fontSize = fontSize; this.textColor = textColor; this.textAlignment = textAlignment; //NSTextAlignmentCenter = 1 this.bgColor = bgColor; this.type = "Label"; } /* JavaScript指令碼 定義一個Button類 * rect:尺寸 text:文字 color:顏色 callFunction:函式 */ function Button(rect,title,fontSize,titleColor,bgColor,callFunction){ this.rect = rect; this.title = title; this.fontSize = fontSize; this.titleColor = titleColor; this.bgColor = bgColor; this.callFunction = callFunction; this.type = "Button"; } /* JavaScript指令碼 Rect類 * x:座標x y:座標y w:寬度 h:高度 */ function Rect(x,y,w,h){ this.x = x; this.y = y; this.w = w; this.h = h; } /* JavaScript指令碼 Color類 * r:red g:green b:black a:alpa */ function Color(r,g,b,a){ this.r = r; this.g = g; this.b = b; this.a = a; } //渲染方法,例項化上面類的物件 function renderUI() { //建立js標籤物件 var screenWidth = 375.0; var labeWidth = 200; var labelRect = new Rect((screenWidth-labeWidth)*0.5, 100, labeWidth, 44); var labeFontSize = 20; var labelTextColor = new Color(1,0,0,1); var labelBgColor = new Color(1,1,0,1); var label = new Label(labelRect, "I From JS", labeFontSize, labelTextColor, 1, labelBgColor); //建立js按鈕物件 var buttonWidth = 200; var buttonRect = new Rect((screenWidth-buttonWidth)*0.5, 200, buttonWidth, buttonWidth); var buttonFontSize = 40; var buttonTitleColor = new Color(1,0,1,1); var buttonbgColor = new Color(1,1,1,1); var button = new Button(buttonRect,"Button",buttonFontSize,buttonTitleColor,buttonbgColor,function(r,g,b){ var randColor = new Color(r,g,b,1); configEntity.chageViewColor(randColor); }); //返回js物件 return [label, button]; }
(2) 建立自定義物件,遵守JSExport協議,新增為JS的全域性物件的屬性,作為與Native互動的橋接器
//自定義Config類 #import <Foundation/Foundation.h> #import <UIKit/UIKit.h> #import <JavaScriptCore/JavaScriptCore.h> NS_ASSUME_NONNULL_BEGIN @protocol ConfigProtocol <JSExport> -(void)chageViewColor:(JSValue *)colorValue; @end @interface Config : NSObject<ConfigProtocol> @property (nonatomic, strong) UIViewController *currentVc; -(void)chageViewColor:(JSValue *)colorValue; //改變當前控制器檢視背景色 @end NS_ASSUME_NONNULL_END
//Created by 夏遠全 on 2019/11/12. #import "Config.h" @implementation Config -(void)chageViewColor:(JSValue *)colorValue { CGFloat red = colorValue[@"r"].toDouble; CGFloat green = colorValue[@"g"].toDouble; CGFloat blue = colorValue[@"b"].toDouble; CGFloat alpha = colorValue[@"a"].toDouble; self.currentVc.view.backgroundColor = [UIColor colorWithRed:red green:green blue:blue alpha:alpha]; } @end
(3) 在VC中解析JavaScript指令碼,獲取UI控制元件元素,進行介面的渲染
#import "ViewController.h" #import <JavaScriptCore/JavaScriptCore.h> #import "Person.h" #import "Config.h" @interface ViewController () @property (nonatomic, strong) JSContext *jsContext; @property (nonatomic, strong) NSMutableArray *actions; //所有的回撥函式 @end @implementation ViewController - (void)viewDidLoad { [super viewDidLoad]; self.view.backgroundColor = [UIColor redColor]; //js上下文 self.jsContext = [[JSContext alloc] init]; //從JS檔案獲取UI進行渲染 [self renderUIFromJs]; } -(void)renderUIFromJs { //建立Config物件 Config *config = [[Config alloc] init]; config.currentVc = self; [self.jsContext setObject:config forKeyedSubscript:@"configEntity"]; //從js檔案獲取js程式碼 NSString *path = [[NSBundle mainBundle] pathForResource:@"UIKit" ofType:@"js"]; NSData *jsData = [NSData dataWithContentsOfFile:path]; NSString *script = [[NSString alloc] initWithData:jsData encoding:NSUTF8StringEncoding]; //執行js程式碼 JSValue *jsValue = [self.jsContext evaluateScript:script]; for (int i=0; i<jsValue.toArray.count; i++) { //取出每一個控制元件物件值 JSValue *subValue = [jsValue objectAtIndexedSubscript:i]; //建立控制元件 NSString *type = [subValue objectForKeyedSubscript:@"type"].toString; if ([type isEqualToString:@"Label"]) { //this.rect = rect; //this.text = text; //this.fontSize = fontSize; //this.textColor = textColor; //this.textAlignment = textAlignment; //NSTextAlignmentCenter = 1 //this.bgColor = bgColor; //this.type = "Label"; CGFloat X = subValue[@"rect"][@"x"].toDouble; CGFloat Y = subValue[@"rect"][@"y"].toDouble; CGFloat W = subValue[@"rect"][@"w"].toDouble; CGFloat H = subValue[@"rect"][@"h"].toDouble; NSString *text = subValue[@"text"].toString; NSInteger fontSize = subValue[@"fontSize"].toInt32; UIColor *textColor = [UIColor colorWithRed:subValue[@"textColor"][@"r"].toDouble green:subValue[@"textColor"][@"g"].toDouble blue:subValue[@"textColor"][@"b"].toDouble alpha:subValue[@"textColor"][@"a"].toDouble]; UIColor *bgColor = [UIColor colorWithRed:subValue[@"bgColor"][@"r"].toDouble green:subValue[@"bgColor"][@"g"].toDouble blue:subValue[@"bgColor"][@"b"].toDouble alpha:subValue[@"bgColor"][@"a"].toDouble]; NSTextAlignment alignment = subValue[@"textAlignment"].toInt32; UILabel *label = [[UILabel alloc] initWithFrame:CGRectMake(X, Y, W, H)]; label.text = text; label.font = [UIFont systemFontOfSize:fontSize]; label.textColor = textColor; label.textAlignment = alignment; label.backgroundColor = bgColor; [self.view addSubview:label]; } if ([type isEqualToString:@"Button"]) { //this.rect = rect; //this.title = title; //this.fontSize = fontSize; //this.titleColor = titleColor; //this.bgColor = bgColor; //this.type = "Button"; //this.callFunction = callFunction; CGFloat X = subValue[@"rect"][@"x"].toDouble; CGFloat Y = subValue[@"rect"][@"y"].toDouble; CGFloat W = subValue[@"rect"][@"w"].toDouble; CGFloat H = subValue[@"rect"][@"h"].toDouble; NSInteger fontSize = subValue[@"fontSize"].toInt32; NSString *title = subValue[@"title"].toString; UIColor *titleColor = [UIColor colorWithRed:subValue[@"titleColor"][@"r"].toDouble green:subValue[@"titleColor"][@"g"].toDouble blue:subValue[@"titleColor"][@"b"].toDouble alpha:subValue[@"titleColor"][@"a"].toDouble]; UIColor *bgColor = [UIColor colorWithRed:subValue[@"bgColor"][@"r"].toDouble green:subValue[@"bgColor"][@"g"].toDouble blue:subValue[@"bgColor"][@"b"].toDouble alpha:subValue[@"bgColor"][@"a"].toDouble]; JSValue *actionValue = subValue[@"callFunction"]; [self.actions addObject:actionValue]; UIButton *button = [[UIButton alloc] initWithFrame:CGRectMake(X, Y, W, H)]; [button setTitleColor:titleColor forState:UIControlStateNormal]; [button addTarget:self action:@selector(buttonClick:) forControlEvents:UIControlEventTouchUpInside]; [button setTitle:title forState:UIControlStateNormal]; button.titleLabel.font = [UIFont systemFontOfSize:fontSize]; button.backgroundColor = bgColor; button.tag = self.actions.count-1; [self.view addSubview:button]; } } } -(void)buttonClick:(UIButton *)button { JSValue *actionValue = [self.actions objectAtIndex:button.tag]; [actionValue callWithArguments:@[@(arc4random_uniform(2)),@(arc4random_uniform(2)),@(arc4random_uniform(2))]]; } - (NSMutableArray *)actions { if (!_actions) { _actions = [NSMutableArray array]; } return _actions; }
(4) 演示gif
六、思考
這個js檔案目前是寫死的放在了Bundle目錄下,試想一下,如果在本地每次更改js檔案後再重新渲染介面,是不是都得端上重新發版,例如樣式相同改一個顏色啥的,發個版就太繁瑣了。最好的做法是將js檔案部署到伺服器上,每次只需要更新js內容後提交到伺服器,端上進入當前頁面時,從伺服器拉取新的js檔案渲染介面即可,效率高,成本低,可以實現快速更新迭代。好開心,吃個竹子吧,