制作一个可以将小游戏快速接入APP的安卓SDK-开篇

【本文参与征文活动】

写在前面

  今年以来,虽然入职的是游戏行业,其实一直在做原生这块的东西,主要是做一个聚合类的SDK,方便其他厂商快速接入,目前安卓这块已经完成了,发现现在市场上对于小游戏转制APP的需求量比较大,其实与我做的聚合SDK有很多相似之处,因此提取出一部分主要思想,共同探讨,因为本人使用的是CocosCreator(以下简称CCC)引擎,所以主要参考用例为CCC,当然本身是支持任意引擎的,当然也包含纯H5游戏。

SDK制作

  本文本身需要一定的安卓基础,但考虑到众多没有安卓经验,但是游戏开发者的需求,略微介绍下SDK制作的方式,如果你已有这方面的经验,可以忽略,众多基本介绍请自行百度,下面以主流且力推的AndroidStudio(以下简称AS)为例。

首先创建一个工程

  目前官方推荐kotlin+androidx,但考虑游戏行业的sdk多没有跟上,推荐各位选择java+android.support

将应用设置成库

  如上,我们只是创建了一个应用,这并不是APP,所以我们需要打开项目(注意工程与项目的概念,与Eclipse不同,简单讲AS的根目录就是工程,项目是app、game这类文件夹(本身命名是可自定义的))的build.gradle,将appliction修改成library,以及删除applicationId。方便各位看到我修改的地方,请看我注释的两行。

编写入口

应用入口

  如果对安卓比较熟悉,就知道库文件是没有上下文的,因此我们要项目传入上下文,相信接过安卓SDK的人都知道,sdk初始化常常是***.init/initSdk(Context context);因此我们也要编写我们的应用入口。

  • 入门级
public static void init(Application app, String appId){
	// TODO
}
  • 个人推荐
public static void init(Application app, SdkConfig config){
	// TODO
}
public class SdkConfig{
	private String appId;
	// TODO
	public static class Builder {
		private String appId;
		public Builder setAppId(String appId){
			this.appId = appid;
			return this;
		}
		public SdkConfig build(){
			SdkConfig config = new SdkConfig();
			config.appId = appId;
			// TODO
			return config;
		}
	} 
}

  这样的好处是能够扩展参数,且是目前流行的链式语法。

生命周期

  类比CCC的组件声明周期,Android也有一套声明周期,一些统计事件可能需生命周期的回调,所以我们也要提供接口。

  • 初级写法
    简单的方式是由用户自行调用,比如
	public static void onResume(Contextcontext){
		// TODO
	}
  • 高级写法
      如果实现该写法,则尽量要保证用户调用sdk的时间够早(PS:其实可以要求用户必须在Application里面调用。)。该方法的实现思想就是使用Android自身的声明周期回调,如下
public static void initSdk(Application app, SdkConfig config){
	// TODO
	app.registerActivityLifecycleCallbacks();
}

编写桥接文件

  虽然前面写了一大堆,其实都是准备工作,在这里才进行到我们的主题,既然原来的方式是用小游戏(js),那么需要在原生使用,则必须要桥接层。本章节主要介绍如何编写。

js脚本

  js脚本的主要作用是实现微信的api,然后借助各引擎的交互连接sdk,为了使用结构清晰,推荐各位根据各平台独立编写一个单独的js文件。例如我编写的针对CCC的脚本文件(部分):

(function(){

    var clzName = "com/dc/sdk/DCJSBridge";

    function BannerAd(id, style) {
        var self = this;
        this.id = id;
        this.showResolve = null;
        this.showReject = null;
        this.loadCallbacks = [];
        this.resizeCallbacks = [];
        this.errorCallbacks = [];

        this.style = {
            width: style.width,
            height: style.height,
            _top: style.top,
            _left: style.left
        };
        this.style.__defineGetter__('top', function () { return this._top });
        this.style.__defineSetter__('top', function (top) {
            try {
                jsb.reflection.callStaticMethod(clzName, "setBannerAdTop", "(II)V", self.id, top);
            } catch (e) { }
            self._top = top;
        });
        this.style.__defineGetter__('left', function () { return this._left });
        this.style.__defineSetter__('left', function (left) {
            try {
                jsb.reflection.callStaticMethod(clzName, "setBannerAdLeft", "(II)V", self.id, left);
            } catch (e) { }
            self._left = left;
        });

        /**
        * 显示 banner 广告
        * @returns {Promise} banner 广告显示操作的结果
        */
        this.show = function () {
            return new Promise(function (resolve, reject) {
                self.showResolve = resolve;
                self.showReject = reject;
                try {
                    jsb.reflection.callStaticMethod(clzName, "showBannerAd", "(I)V", self.id);
                } catch (e) {
                    reject();
                }
            });
        };

        /**
        * 监听 banner 广告尺寸变化事件
        * @param {Function} callback banner 广告尺寸变化事件的回调函数
        */
        this.onResize = function (callback) {
            this.resizeCallbacks.push(callback);
        };

        /**
        * 监听 banner 广告加载事件
        * @param {Function} callback banner 广告加载事件的回调函数
        */
        this.onLoad = function (callback) {
            this.loadCallbacks.push(callback);
        };

        /**
         * 监听 banner 广告错误事件
         * @param {Function} callback banner 广告错误事件的回调函数
         */
        this.onError = function (callback) {
            this.errorCallbacks.push(callback);
        };

        /**
         * 取消监听 banner 广告尺寸变化事件
         * @param {Function} callback banner 广告尺寸变化事件的回调函数
         */
        this.offResize = function (callback) {
            if (null == callback) {
                this.resizeCallbacks = [];
                return;
            }
            for (var i = 0; i < this.resizeCallbacks.length; i++) {
                if (callback == this.resizeCallbacks[i]) {
                    this.resizeCallbacks.splice(i, 1);
                    i--;
                    break;
                }
            }
        };
        /**
         * 取消监听 banner 广告加载事件
         * @param {Function} callback banner 广告加载事件的回调函数
         */
        this.offLoad = function (callback) {
            for (var i = 0; i < this.loadCallbacks.length; i++) {
                if (callback == this.loadCallbacks[i]) {
                    this.loadCallbacks.splice(i, 1);
                    i--;
                    break;
                }
            }
        };
        /**
         * 取消监听 banner 广告错误事件
         * @param {Function} callback banner 广告错误事件的回调函数
         */
        this.offError = function (callback) {
            for (var i = 0; i < this.errorCallbacks.length; i++) {
                if (callback == this.errorCallbacks[i]) {
                    this.errorCallbacks.splice(i, 1);
                    i--;
                    break;
                }
            }
        };
        /**
         * 隐藏 banner 广告
         */
        this.hide = function () {
            try {
                jsb.reflection.callStaticMethod(clzName, "hideBannerAd", "(I)V", this.id);
            } catch (e) {
            }
        };
        /**
         * 销毁 banner 广告
         */
        this.destroy = function () {
            try {
                jsb.reflection.callStaticMethod(clzName, "destroyBannerAd", "(I)V", this.id);
            } catch (e) {
            }
        };
        return this;
    }

    var dc = window.dc || function(){};

    dc.getSystemInfoSync = function(){
        try {
            var str = jsb.reflection.callStaticMethod(clzName, "getSystemInfo", "()Ljava/lang/String;");
            // 防止特殊字符等,进行了Base64编码
            if(null == str || "" == str) {
                return null;
            }
            var msg = atob(str);
             return msg;
        } catch(e) {
            return null;
        }
    };
    dc.bannerAds = [];
    dc.createBannerAd = function (object) {
        // TODO 判断是否缺少必须参数
        try {
            // 如果想性能高点,则使用对应参数
            // var id = jsb.reflection.callStaticMethod(clzName, "createBannerAd", "(Ljava/lang/String;IIIII)I", object.adUnitId, object.adIntervals || 30, object.style.left, object.style.top, object.style.width, object.style.height);
            // 如果想扩展方便,则可以直接传字符串,然后在java层进行解析
            var id = jsb.reflection.callStaticMethod(clzName, "createBannerAd", "(Ljava/lang/String;)I", JSON.stringify(object));
            if (id < 0) {
                return null;
            }
            dc.bannerAds[id] = new BannerAd(id, object.style);
            return dc.bannerAds[id];
        } catch (e) {
            return null;
        }
    };
})();

各引擎调用的方法基本类似,各位如果有对应的经验,其实非常简单。

原生代码

  这里其实是工作的一大重点,但是考虑各位这篇文章应该有基本的Sdk集成经验,而且工作相对重复且多,本文可能没法进行详述。
  需要注意的是,这是sdk开发,我们不能像以往接sdk一样,某个地方突然想调用一下js就调用,而应该集中起来,先调用java,然后由java调用对应的js代码,好处是java原生游戏其实也能用这套逻辑,另外一点是下面提到的调用脚本的实现。
  推荐各位多使用代理类,实现各个方法的接口,然后由代理类去调用各个sdk的具体实现。

加载js脚本

  js对安卓而言仅仅是一个资源,要引擎加载,则还需要引擎加载,H5的话,直接在index中加载该文件即可。而CCC则在main.js中。

调用脚本

  现在我们解决了引擎调用原生的问题,那原生如何调用脚本呢?如果你是CCC开发人员,你可能马上想到了引擎提供的Cocos2dxJavascriptJavaBridge.evalString(String str);方法,可是我们是Sdk啊,总不能还集成CCC的引擎层吧,而且还有laya等其它引擎,这该怎么办呢?其实各位这里就不要陷入死胡同了,我们要做的是提供原生能力,而不是考虑实现,所以这里我们应该提供一个入口给原生,有用户决定js代码如何实现。

js回调监听

public interface JSEval {
    void callJS(String code);
}

传入监听

    private static List<JSEval> evals = new ArrayList<>();
    public static void registerJSEval(JSEval eval){
        evals.add(eval);
    }

用户层实现

        DCAgent.registerJSEval(new DCAgent.JSEval() {
            @Override
            public void callJS(final String jsCode) {
                runOnGLThread(new Runnable() {
                    @Override
                    public void run() {
                        Cocos2dxJavascriptJavaBridge.evalString(jsCode);
                    }
                });
            }
        });

这样就简单的将跨引擎的事移交(甩锅)给用户了。

打包

  打包的方式有多种,可以是aar,或者流行的maven仓库,这个网上博客较多,不在赘述,仅提一句:注意混淆。

H5大厅游戏APP如何修改

  这个sdk其实更适合那些想把小游戏变成原生游戏的用户使用,但是H5大厅模式的,其实原理一样,只不过sdk就变成自己使用了,自己集成sdk,然后将主页变成大厅,根据点击实现各H5游戏的实现,即可实现一个简单的H5大厅游戏,当然其中的性能优化、对接就需要各位继续探索了。

结尾

  一开始准备写的细节,写着写着突然就不知道该总结到哪,比如sdk的集成方式,广告的实现逻辑,如果单独一个章节,感觉又偏离主题,更像一个安卓开发的主题,不写,总感觉重要的东西丢失。只能蜻蜓点水提上几句,只能期待后期继续改进。
  本文的经验来自我在公司的工作内容,因此有些不想关或者涉及重要核心的地方都省略了,另外代码也重新手敲,难免有些错误,敬请谅解。本文可能更加注重的是这个开发思维,更多细节,希望与我一起讨论。

3赞

用的WebView封装而不是纯原生方式?那百度随便找个封装app的网站就行了,各种SDK功能一大堆

而且WebView封装上架估计很困难

虽然你没有看文章,那也正好借楼说明下该文的作用,对于一些小游戏厂商而言,有大量的游戏想转制成app,如果每个游戏都单独做,成本高时间久,而本文就是介绍如何开发一个sdk,来抹平各平台、引擎之间的差异,迅速对接小游戏,这样就不需要修改代码即可直接打出APP包,而有些厂商可能还想做一个盒子之类的应用,而正因为这个sdk是跨引擎的,因此非常容易实现。此文,只是以CCC为辅助,介绍该sdk的实现与作用。所以本文更加偏向面向原生开发的人,而不是纯粹的“脚本游戏开发者”

2赞

感觉很有搞头,楼主上传到github了么。。。
另外问一句,这种跟聚合类的sdk有什么区别???

这个本质了是一种聚合sdk,只是解决某一种用途而修改的,通过桥接js层,完成小游戏的api实现

:+1::+1::+1::+1::+1:

MARK.

以下是我的一些想法:

  • init 的上下文一般是 Context ,不是 Application,内部获取 ApplicationContext
  • 上下文其实可以通过 ContentProvider 去处理,这样子就不用外部传 Context 了
  • 回调的 js 代码如果是含有 " 双引号,一般建议转义一下(如Base64Encode),因为 java 中 " 双引号可能是用 " 表示的,这个在js中可能无法反序列化。这个问题通常在回调js参数中存在json字符串的时候会容易遇到
  • 最后一般打包aar就可以了,不然每次都要依赖 library

另外我觉得文章的重点是 制作一个 xxx SDK 这里的重点是

  1. js怎么调用原生的某个类的静态方法?
  2. 原生怎么调用js对象的某个函数?
  3. SDK 怎么制作,怎么打包 SDK 给别人使用?
  4. CCC 怎么用这个 SDK?

但是,除了第1点讲了用jsb调用原生静态方法,其他都没详细讲

  • 第2点,文中比较含糊(Cocos2dxJavascriptJavaBridge.evalString 这个函数调用什么才是重点),整个流程比较难以跑起来
  • 第3点,只将了怎么在AS新建一个项目,并没有后续怎么讲打包这个SDK的操作,只是简单描述了一句话,也没有相关文献
  • 第4点,也是比较关键的,没有讲怎么使用刚刚制作好的SDK

谢谢指正。
1.关于context的问题,设计通信院SDK的调用,以及一些投放SDK的调用,无可奈何,当然如果没有这两个需求,你的方法是可以的。
2.js调用,我一直提倡的是base64,只是我截取的代码,基本没有使用到调用string的问题。
3.打包成aar当然可以,但是打包成maven更适合推广,这个要看公司的用途。
4.关于你讲的几个重点,我会在后面的系列文章中重点描述,这个算一个总纲。
我将在近期,陆续放出后续文章,因为涉及知识点比较多,所以需要多篇进行单独描述。

想法是好的,但是你分享的,好像没啥关系

建议具体一点

仅目前而已,似乎和Cocos已有的SDKWrapper.java功能类似,仅多封装了一下jsjava互相调用。期待一下后续的分享

近期就会有完整的文档出来,初步方案是这样的

聚合类SDK的方案基本类似,js与java的交互本身属于小游戏转制APP的关键点。

封装Webview是大厅H5游戏的需要,普通的单游戏方案,直接原生就行

请恕我直言,这个截图中的标题充斥着 大量加粗的冗余信息 ,这些信息从技术上看,一点用都没有:joy:

技术类文章,我还是喜欢朴素一点的技术话语

1赞

现在不封装是过不了审核吗?