小程序移动端方案分享


前端时间有朋友咨询如何实现一个小程序的框架,可以方便的集成到自己的应用中,方便之后前端更新UI。之前自己曾经调研过小程序的技术原理,这里分享这篇博客目的是对小程序移动端的方案进行一些实践。下面就分享下小程序移动端的实践。首先看下小程序整体的一张架构图。

小程序架构图.png

其实小程序的核心思想还是逻辑层和渲染层分离,这样Native可以把逻辑层的代码放到一个单独的线程中,渲染层只负责页面的展示,从而提高了Webview上显示的效率。所以Native的开发核心就是约定逻辑层和渲染层同上层前端代码的协议。

小程序源码的分析

首先可以看下微信小程序的IDE工具:

微信小程序.png

微信小程序的IDE工具主要是基于NW开源框架开发的,我们这里主要是基于electron这个web技术构建桌面应用框架,写的小程序IDE的demo。其实本质上两个框架是大同小异的。下面看下百度开源的小程序IDE工具的大致架构图:

百度小程序IED.png

小程序IDE.png

基于上面的框架,构建了小程序IDE示例程序,demo的源码目录结构如下: IDE目录.png

开发者主要是在app目录下开发,基于vue的模版语法进行页面编写,这个demo示例主要有index.cloud、index.css、index.js文件。 index.cloud代码如下:


<page class="container">
    <h1>hello</h1>
    <p>!</p>
    <p></p>
    <br />
    <button @click="say">666666</button>
    <br />
    <input :value="content" @change="handleInputChange"></input>
    <p></p>

    <button @click="go">page 2!</button>
</page>

index.css代码如下:


.container{
    width: 100vw;
    height: 100vh;
    padding: 20px;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    box-sizing: border-box;
}
button{
    width: 100%;
    padding: 15px;
    text-align: center;
    font-size: 18px;
    background: black;
    color: white;
    font-weight: 900;
}

input{
    box-sizing: border-box;
    width: 100%;
    padding: 15px;
    text-align: center;
    font-size: 18px;
    font-weight: 900;
}

index.js代码如下:


Page({
    data() {
        return {
            who: 'xxxx',
            what: 'uuu',
            content: 'empty!'
        }
    },
    watch: {
        who(val){
            if(val === 'ppppp'){
                this.setData('what', 'nnn')
            }
            if(val === 'aaaa'){
                this.setData('what', 'ananana')
            }
        }
    },
    mounted(){
        this.setData('who', 'ppppp')
    },
    methods: {
        say(){
            this.setData('who',  Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15));
        },
        go(){
            beacon.navigateTo('page2', { content: this.content, b: 3 })
        },
        handleInputChange(value){
            this.setData('content', value);
        }
    }
})

然后小程序通过master来解析用户的逻辑代码,生成数据,通过setData的方式传递给小程序slave的渲染层,这个传递的过程在IDE中是通过调用simulator层来模拟的,其实放到客户端的话,就是客户端需要实现的逻辑层js的运行环境。由于百度开源的小程序,移动端的逻辑并没有开放,所以我们根据百度小程序IDE开源的实现,做了一些分析,实现了移动端native的代码逻辑。下面我们就结合我们移动端的demo来看下具体的实现。

ios小程序sdk的实现

首先我们先看下上面的IDE中main.js中注册的一些模拟器方法


slave: {
    page: {
        ready(...payload){
            logger.info(payload)
            schemas.simulator.webview.distributeMessage('simulator', ...payload)
        },
        setData(...payload){
            schemas.simulator.webview.distributeMessage('simulator', ...payload)
        }
    }
},
master: {
    page: {
        startListen(){
            schemas.native.page.navigateTo('native/page/navigateTo', routes[0]);
            logger.info(slaveLocations[0]);
            master.openDevTools();
        },
        hook(schema, ...payload){
            master.webContents.send('simulator', schema, currPage, ...payload)
        },
        callFn(schema, ...payload){
            master.webContents.send('simulator', schema, currPage, ...payload)
        },
        prepare(schema){
            // 准备页面
            master.webContents.send('simulator', schema, currPage);
        },
        destroy(schema, page) {
            logger.info(schema, page)
            master.webContents.send('simulator', schema, page);
        },
    }
},
native: {
    page: {
        navigateTo(schema, url, payload){
            logger.info(`navigateTo ${url}`)
            // 获取页面的对象序号,修改当前集中的页面到序号页面
            currPage = routes.findIndex((p) => p === url);
            // 创建 webview ,获取 webviewid
            const query = payload && queryString.stringify(payload);
            const location = `${host}${url}.html${query ? `?${query}` :''}`;
            logger.info(`navigateTo ${url}`)
            schemas.simulator.webview.createWebview(location, currPage);
            // 压入页面栈
            push({
                pageIndex: currPage,
                path: routes[url],
                location,
                query: payload
                // page
            })
        },
        navigateBack(schema, payload){
            if(isBottom()) return;
            schemas.master.page.destroy('master/page/destroy', currPage);
            schemas.simulator.webview.destroyWebview(currPage);
            const curr = pop();
            currPage = curr.pageIndex;

        },
        getQuery(schema){
            master.webContents.send('simulator', schema, currPage, getCurrentCache().query);
        }
},
app: {
        onLaunch(schema, options){

        },
        onShow(schema, options){

        }
    }
}

下面我分析下移动端如何配合前端代码,来实现最终渲染的。首先看下ios端小程序sdk的代码目录结构:

ios目录结构.png

ios的逻辑层代码是基于JSContext实现,渲染层是基于WKWebview实现。jscontext层的核心代码是实现下面的协议:


typedef void (^JSCallBack)(NSString * schema,id args);

@protocol NEBridgeProtocol <NSObject>

@property (nonatomic, readonly) JSValue* exception;

- (void)evalJavascript:(NSString *)script;

- (JSValue *)callJSMethod:(NSString*)method args:(NSArray*)args;

- (void)listenJSEvent:(NSString*)name callback:(JSCallBack)callback;

@end

上面的代码是逻辑层和前端代码沟通的核心,等下我会配合刚才IDE的业务代码分析下如何通信的。

渲染端的核心代码如下:

@protocol NEWebViewBridgeProtocol

@required
- (void)receiveMessageHandler:(void (^ _Nullable)(WKScriptMessage * _Nonnull message))completionHandler;
- (void)evaluateJavaScript:(NSString *_Nullable)javaScriptString;

@end

客户端如何同前端通信

有了前面基础理论的铺垫之后,我们来看下前端如何同客户端通信的,首先我们需要把逻辑层,也就是JSContext的运行环境放到一个单独的线程中,下面我贴出来一部分核心代码:


- (void)runJSCode:(NSString*)code {
    @weakify(self);
    dispatch_async(self.threadQueue, ^{
        @strongify(self);
        [self.jsBridge evalJavascript:code];
    });
}

- (void)registerEvent:(NSString*)name callback:(JSCallBack)block {
    [self.jsBridge listenJSEvent:name callback:block];
}

然后就是注册逻辑层的监听事件,相当于模拟electron工程的simulater的实现。


NEBridgeContext * context = [NEBridgeContext createInstance:@"netease"];
NSString * code = [[NSString alloc] initWithContentsOfFile:path encoding:NSUTF8StringEncoding error:NULL];
@weakify(self);
[context registerEvent:@"startListen" callback:^(NSString *schema,NSDictionary * args) {
    @strongify(self);
    dispatch_async(dispatch_get_main_queue(), ^{
        [self createWebview];
    });
}];
[context registerEvent:@"navigateBack" callback:^(NSString *schema,NSDictionary * args) {
    @strongify(self);
    dispatch_async(dispatch_get_main_queue(), ^{
        [self.navigationController popViewControllerAnimated:YES];
    });
}]; 
[context registerEvent:@"navigateTo" callback:^(NSString *schema,id args) {
    @strongify(self);
    if ([args isKindOfClass:[NSArray class]]) {
        NSArray * paras = (NSArray*)args;
        self.queryData = paras[1];
        dispatch_async(dispatch_get_main_queue(), ^{
            [self navigationTo:paras[0]];
        });
    }
}];
[context registerEvent:@"setData" callback:^(NSString *schema, id args) {
    @strongify(self);
    [self handleSchema:schema args:args];
}];
[context registerEvent:@"ready" callback:^(NSString *schema,id args) {
    @strongify(self);
    [self handleSchema:schema args:args];
}]; 
[context registerEvent:@"getQuery" callback:^(NSString *schema, id args) {
    dispatch_async(dispatch_get_main_queue(), ^{
        NSLog(@"Logic excute : %@",code);
        [context runJSCode:code];
    });
}]; 
[context runJSCode:code];

其实上面的逻辑就是刚才提到的master做的主要事情。接收逻辑层的数据,然后通知客户端来创建webview,以及做一些native的控件和动画。之后进入到渲染层的处理,渲染层的代码逻辑如下:


[self.mpView receiveMessageHandler:^(WKScriptMessage * _Nullable message) {
        @strongify(self);
        [self receiveRenderMessage:message webView:self.mpView];
}];

- (void)receiveRenderMessage:(WKScriptMessage *) message webView:(MNPWebView *) currentWebView {
    NSArray * postMessages = (NSArray *)message.body;
    NSLog(@"Render message: %@",postMessages);
    if (postMessages.count == 0) {
        return;
    }

    NSString * code = [NSString stringWithFormat:@"communicator.emit("];
    NSInteger index = 0;
    for (id para in postMessages) {
        if (index == 0) {
            code = [NSString stringWithFormat:@"%@\"%@\",%ld",code,para,(long)currentWebView.pageIndex];
        }
        else {
            code = [NSString stringWithFormat:@"%@,\"%@\"",code,para];
        }
        index ++;
    }
    code = [NSString stringWithFormat:@"%@)",code];
    NEBridgeContext * context = [NEBridgeContext createInstance:@"netease"];
    NSLog(@"Logic excute:%@",code);
    [context runJSCode:code];
}

渲染层主要是接收一些用户触发的事件,然后告诉逻辑层来获得数据,逻辑层准备完毕数据后,就再给渲染层,渲染层来渲染页面,最终显示出来。 至此客户端就把逻辑层和渲染层代码分离开了,可以分别的处理不同的事情。下面我们就看下demo的效果。

IDE环境和小程序的Demo

小程序的IDE其实是基于Electron的工程,我们需要启动Electron的模拟器和调试器,用户是通过编写VUE的模版代码和js代码来创建小程序的。如果说要实现客户端动态调试的话,其实是需要客户端创建websocket的连接,前端代码更新的时候,实时通知客户端进行渲染加载,目前demo还没有做这个事情,只是简单的分离了逻辑层和渲染层。下面我们看下我们demo的IDE的长的样子吧。

首先我们通过如下命令

cd simulator-shell/
node launch

启动模拟器的IDE环境,然后进到工程目录中启动当前小程序代码

npm run dev

结果如下图:

模拟器.png

小程序demo在编写完毕的时候,通过webpack打包输出master.js、page.js、index.html这些文件。然后客户端分别加载逻辑层master.js的代码和渲染层index.html运行这个demo。

小程序完整方案

逻辑层和渲染层分离只是小程序的一个核心功能,如果要做出来一个完成的小程序,当然还需要很多工程化的事情。这里就说下完整的方案需要的技术能力.

  1. Web资源离线缓存能力,小程序往往有大量的静态资源比如webpack打包好的渲染层的代码,以及图片音视频资源,这些如果能做离线缓存,就可以大大提升小程序的性能。

  2. 静态资源更新的能力,这个就涉及到小程序更新的逻辑。由于静态资源远程加载一般都需要CDN来做加速,CDN节点往往都有资源缓存,所以对静态资源如何做更新哪?一般通用的方法时,更新静态资源版本号的方式,或者对资源做差量计算。这就要根据具体采用的方案进行细化了。

  3. 小程序的远程调试能力。小程序开发者使用开发工具完成开发后,需要有一定的能力预览上线后的样式,然后可以远程调试定位问题。这里往往需要提供websocket的能力给开发者方便定位问题。

  4. native拓展能力,如果开发者,想要用native的代码做一些自定义动画,或者一些自定义控件的话,如果小程序可以方便的把一些native的控件和动画作为插件,集成到小程序的UI库中的话,就可以大大提升小程序的拓展能力。

  5. 对一些JS语法能力的一些拓展。

小程序前景展望

小程序的重要一点就是可以做到实时发布一些功能。尤其对一些轻量级的页面,比如一些活动页面特别的有用。同时小程序又能让开发者使用一些native的功能,让用户体验上接近native。其实对于很多第三方的app都有开发自己小程序的需求,如果说能够开放出来一个小程序的生态环境,相信会吸引很多开发者入住。目前w3c已经提出了小程序草案。也是希望小程序有一个统一的标准,能更好的的服务广大的开发者。

当然我上面的分析只是针对小程序移动端sdk的一个笼统的介绍,如果需要做一个完善的小程序产品,还需要大量工程化的事情,比如我们要有大量的vue组件库和native的组件库,提供给开发者使用。IDE开发工具肯定也要做一些个性化的更改,比如热更新和调试功能。整个工程下来,可能并不像我这篇文章描述的这么简单,我这里只是分析了下目前小程序可行的一种方案。在技术方案可行的前提下,其实后期就是人力投入和产品打磨的过程了。往往一个成功的产品可能后期的打磨会更关键一些。也想借助这篇博客抛砖引玉,希望能够引起大家对小程序这个产品的兴趣。

参考

如果你喜欢这篇文章,谢谢你的赞赏

图3

如有疑问请联系我