跨端开发面面谈之基于WebView的Hybrid开发模式一文总结了基于WebView的混合开发模式的发展路线和在各个方向的优化方式,简单介绍了基于这一模式的Ionic框架的应用和工具生态。本文继续跨端开发系列,谈一谈对NativeScript开发框架的理解。由于没有在生产环境、复杂应用上深入使用NativeScript,所以文中观点可能不够准确,更多的,是作为一个有经验的开发者,接触之后,结合一些文章的阅读,一点个人的理解。
从WebView到Native
基于WebView的混合开发模式在国内外得到广泛应用,开发者深入各个方向对其进行了各种优化,输出了很多最佳实践。大家想尽办法想要提升体验、抹平差异,是因为我们将Web App运行在WebView容器中,与Native App天然存在性能差距。此时便出现了新的思路,依然保持Web技术栈的开发体验,内置一个运行时环境,通过映射,使得最终用户操作的是原生视图,以此提供一个接近原生体验的高性能应用。
NativeScript是我了解的第一个实现这一思路的框架,采用这一框架,开发者在应用开发时采用Web技术栈,而构建出的App均为原生视图。自此,在理论上打破了WebView性能天花板,跨端开发技术从WebView迈向了Native侧。
NativeScript Hello World
NativeScript支持以多种前端技术栈来开发应用,最初其支持model view view model这一MVVM模式,以原生JavaScript业务逻辑/xml视图模板/css样式模式开发,后来引入对Angular 2的支持并持续跟进Angular的迭代,当前社区也提供了以Vue进行开发的支持。
不引入其他前端框架时demo 结构如图
其中,xml模板如下
<Page loaded="loaded">
<Page.actionBar>
<ActionBar title="Sign in"></ActionBar>
</Page.actionBar>
<StackLayout orientation="vertical">
<Image src="~/images/logo.png" stretch="none" horizontalAlignment="center" ></Image>
<TextField hint="Email Address" id="email" text="{{ email }}" keyboardType="email" autocorrect="false" autocapitalizationType="none"/>
<TextField hint="Password" text="{{password}}" secure="true"/>
<Button text="Sign in" tap="signIn"/>
<Button text="Sign up for Groceries" class="link" tap="register"/>
</StackLayout>
</Page>
JavaScript model及view model为
//login.js
var frameModule = require('ui/frame');
var UserViewModel = require('../../shared/view-models/user-view-model')
var dialogsModule = require('ui/dialogs');
var user = new UserViewModel()
var page;
var email;
exports.loaded=function (args) {
page=args.object;
page.bindingContext = user;
}
exports.signIn = function () {
user.login()
.catch(function (error) {
dialogsModule.alert({
message:'Unfortunately we could not find your account',
okButtonText:'OK'
})
})
.then(function () {
frameModule.topmost().navigate("views/list/list")
})
}
exports.register=function () {
var topmost = frameModule.topmost();
topmost.navigate('views/register/register');
}
//user-view-model.js
var config = require("../../shared/config");
var fetchModule = require("fetch");
var observableModule = require("data/observable");
function User(info) {
info = info || {};
// You can add properties to observables on creation
var viewModel = new observableModule.fromObject({
email: info.email || "",
password: info.password || ""
});
viewModel.login = function() {
return fetchModule.fetch(config.apiUrl + "user/" + config.appKey + "/login", {
method: "POST",
body: JSON.stringify({
username: viewModel.get("email"),
password: viewModel.get("password")
}),
headers: getCommonHeaders()
})
.then(handleErrors)
.then(function(response) {
return response.json();
})
.then(function(data) {
config.token = data._kmd.authtoken;
});
};
viewModel.register = function() {
return fetchModule.fetch(config.apiUrl + "user/" + config.appKey, {
method: "POST",
body: JSON.stringify({
username: viewModel.get("email"),
email: viewModel.get("email"),
password: viewModel.get("password")
}),
headers: getCommonHeaders()
}).then(handleErrors)
};
return viewModel;
}
function getCommonHeaders() {
return {
"Content-Type": "application/json",
"Authorization": config.appUserHeader
}
}
function handleErrors(response) {
if (!response.ok) {
console.log('error')
console.log(JSON.stringify(response));
throw Error(response.statusText);
}
return response;
}
module.exports = User;
样式文件login.css为
TextField {
border-width: 5;
border-style: solid;
border-color: #034793;
}
Image {
margin: 10;
}
Button {
margin: 10;
padding: 10;
}
上述代码可以看到,视图模板中,使用了NativeScript封装的组件系统,包括各式布局组件和具体视图组件。在view model与view之间,框架提供了对事件绑定和数据绑定的处理。在view model与model之间,框架也提供了一些工具方法,提供了异步请求和数据更新的支持。
NativeScript提供对Angular的支持,一个完整的NativeScript Angular示例,推荐阅读使用NativeScript和Angular2构建跨平台APP,文中使用Angular和NativeScript实现了一个ios calculator。
NativeScript Inside
NativeScript运行机制如下图所示,其核心包括NativeScript Runtimes, Core Modules, CLI, Plugins。
结合上图,深入看下NativeScript内部实现原理。NativeScript一大卖点,是其号称可以直接获取全部的系统及第三方模块的公共API的调用。这是怎么实现的呢,以Android为例,是通过反射机制实现。在通过cli命令行工具构建应用时,框架会通过反射,获取所有原生公共API,将这些信息以二进制结构作为元数据信息打进应用包。当JavaScript端调用这些API时,Runtime会根据元数据信息反射调用相应API。
基于NativeScript Runtime获取Native API Access,NativeScript提供了Core Modules,这是框架提供的抽象层,包含了对视图、系统API、布局的统一抽象,其write once, run anywhere的核心正是基于这一跨平台抽象层的封装实现。
当已有Module不能满足应用的个性化需求时,NativeScript支持Plugins扩展。你可以选择使用社区第三方plugin或者实现自己的plugin。plugin可以是对已有组件的组合包装,也可以是对原生组件的自定义封装。
Bye,NativeScript
看上去很美,是我在初了解NativeScript后的感觉。对于前端开发者来说,无需切换技术栈,任意挑选原生JavaScript/Angular/Vue,利用框架组件库和API封装,快速开发出高性能跨平台应用,是不是感觉特别美好?
继续深入了解其核心,在美好背后,在我看来,NativeScript主要有下面三个问题
- Write everything in JavaScript
- NativeScript run JavaScript on the main UI thread
- Integrating existing native apps
write everything in JavaScript是伪命题
NativeScript提出的重要卖点,write everything in JavaScript。在跨终端混合应用开发过程中,始终使用你精通的JavaScript,无需学习Java/Object-C,挺起来确实有吸引力。但是,总是有但是,当你开始真正在实际项目中使用它,复杂度超过Hello World demo时,你一定会遇到某些个性化特性,框架没有帮你实现,社区中也没有已有实现(已NativeScript社区活跃度来看,这个概率很大),这个时候该怎么办呢?NativeScript支持plugin插件机制,并且,in JavaScript。
假设你需要扩展一个功能更复杂的ListView,让我们先参考学习官方ListView的实现。
在node_modules/tns-core-modules/ui/路径下可以看到list-view代码实现,其结构如上图所示,.d.ts中描述结构定义,.common.js中定义通用逻辑,在.android.js、.ios.js中,分平台实现平台特有逻辑。
ListView.prototype.createNativeView = function () {
initializeItemClickListener();
var listView = new android.widget.ListView(this._context);
listView.setDescendantFocusability(android.view.ViewGroup.FOCUS_AFTER_DESCENDANTS);
listView.setCacheColorHint(android.graphics.Color.TRANSPARENT);
ensureListViewAdapterClass();
var adapter = new ListViewAdapterClass(this);
listView.setAdapter(adapter);
listView.adapter = adapter;
var itemClickListener = new ItemClickListener(this);
listView.setOnItemClickListener(itemClickListener);
listView.itemClickListener = itemClickListener;
return listView;
};
function ListView() {
var _this = _super.call(this) || this;
_this.widthMeasureSpec = 0;
_this.nativeViewProtected = _this._ios = UITableView.new();
_this._ios.registerClassForCellReuseIdentifier(ListViewCell.class(), _this._defaultTemplate.key);
_this._ios.estimatedRowHeight = DEFAULT_HEIGHT;
_this._ios.rowHeight = UITableViewAutomaticDimension;
_this._ios.dataSource = _this._dataSource = DataSource.initWithOwner(new WeakRef(_this));
_this._delegate = UITableViewDelegateImpl.initWithOwner(new WeakRef(_this));
_this._heights = new Array();
_this._map = new Map();
_this._setNativeClipToBounds();
return _this;
}
当我分别在list-view.android.js、list-view.ios.js中看到上述代码时,这种美好的感觉不再有了。我开始问自己,我是谁、我在哪、我在干什么?
NativeScript可以获取native API,基于这一特性,其plugin扩展的逻辑,是让你用JavaScript实现原生代码逻辑。那么问题来了,这真的是为前端开发者准备的吗?因为,作为前端开发者的我,对原生平台的API,是真的做不到这么熟悉的。而让各平台客户端开发者以JavaScript来实现这一逻辑,又要让他们了解JavaScript写法,并且,这一代码可调试性不足,这样非但不能提升生产力,还会起到相反效果。
即使不是封装自定义组件,在普通业务开发中,也有可能遇到这一问题。以前文提到的使用NativeScript和Angular2构建跨平台APP为例,在这个计算器应用中,为实现结果展示区域文字自适应缩放,在展示组件displayer中有如下代码
ngOnChanges(changes: {[propertyName: string]: SimpleChange}) {
//Android Only
if(device.os !== platformNames.android) return;
for (let propName in changes) {
let chng = changes[propName];
let cur = JSON.stringify(chng.currentValue);
let prev = JSON.stringify(chng.previousValue);
console.log('current ' + cur + ' previous ' + prev);
if(this.displayer && this.wrapper) {
let paint:any = this.displayer.android.getPaint();
let textWidth:number = paint.measureText(cur);
let wrapperWidth:number = this.wrapper.android.getWidth();
if(Math.abs(wrapperWidth - textWidth) < 50 && this.currentFont > Displayer.MIN_FONT) {
this.currentFont -= 10;
}
if(Math.abs(wrapperWidth - textWidth) > wrapperWidth / 2) {
this.currentFont = Displayer.MAX_FONT;
}
this.displayer.android.setTextSize(this.currentFont);
}
}
}
setIOSLabelAutoFont(elem: Label) {
if(device.os === platformNames.ios){
elem.ios.numberOfLines = 1;
elem.ios.minimumFontSize = Displayer.MIN_FONT;
elem.ios.adjustsFontSizeToFitWidth = true;
}
}
上述代码中,在iOS平台,使用了adjustsFontSizeToFitWidth属性,而安卓平台没有这一属性,为了实现这一效果,在安卓平台基于canvas计算文字实际渲染宽度,再计算缩放字号。这种大段以JavaScript来完成原生逻辑的代码写在业务代码中,看的实在是难受。
另外,文中提到如下内容,这是不对的。直接实现四个字该如何定义,上述NativeScript的实现算不算直接实现?在React Native中,一样可以引入Canvas组件,先计算出文字实际渲染宽度,再根据缩放比例完成文字缩放。
这个交互,在 ReactNative 中,是无法直接实现的! 这也是 NativeScript 相比于 ReactNative 的强大之处。
在我看来,在一个混合应用开发过程中,必然有对原生开发部分的需求,这一部分如果让前端开发者来完成,就必然需要前端开发者学习原生技术栈,这没有问题。这一部分如果让原生开发者来完成,也没有问题。但是这一部分对原生端的依赖,应该放在原生端实现,再以其他方式直接扩展给JavaScript调用即可,像这样,以JavaScript来实现两套原生逻辑,再封装调用方式,以此为卖点,个人实在是接受不了。
run JavaScript on the main UI thread可能是炸弹
The Benefits of NativeScript’s Single Threading Model一文,解释了NativeScript的线程模型,说明了框架选择将JavaScript放在UI线程运行的考虑,主要有下面两点
- Running app code on the UI thread by default allows NativeScript to expose all native APIs to app JavaScript in an efficient and high performing way.
- Many common scenarios that require background thread processing are already covered by NativeScript plugins and modules.
简单来说,这一模式去掉其他bridge,让JavaScript在UI线程同步执行,可以带来更高的性能,一些常见的需要异步处理场景,框架本身通过扩展机制已经帮你在其他线程完成了处理。
那么,如果一个开发者在JavaScript代码中写下如下代码呢?
while(i<100000000){}
run JavaScript on the main UI thread去掉了异步通信过程,的确能带来更高的性能,但是这种模式,无法避免开发者在代码中埋下的炸弹,相比将JavaScript运行在独立线程,这一模式被这种炸弹毁掉的概率大得多。
Integrating existing native apps支持不足
在我的印象中,React Native面世时的火爆,主要因为两个方面,一是其热更新能力,二是其便捷的与已有App集成的能力。而NativeScript在其推出时,是一个非此即彼的排他性框架。没有具体去查演进历史,大概印象是其从1.0迭代到3.0时,还不支持与原生应用集成,到现在4.0版本,可以集成进原生应用中。但是,查看文档,在原生应用集成方面,文档不太完善。查看指向的GitHub项目,集成的复杂程度,高于React Native,我没有在生产项目中集成使用它的经验,只是从已有资料来看,其对这一块的支持刚刚开始,还不完善。
最后
NativeScript问世已久,一直没有大火。去年React专利风波时,知乎上有安利过NativeScript 简介,一阵风过,好像没有下文。我本以为是大家没有发现这块良玉,一番探索后,决定从入门到放弃。
当然我也没有在生产环境长期使用过,所以我的感受可能都是错的,仅从个人体验,不推荐使用这一框架,而如果你实在偏爱Angular,推荐Ionic了解一下,偏爱Vue,Weex了解一些。各路阵营都有其他方案,实在不必踩这里的坑。