Lua 简单面向对象模型

Lua 使用 table 来模拟各种数据结构,当然,也可以用它来模拟类(Class)。要实现类的模拟,我们需要找到一种方式来定义类,并从类创建实例,实现继承等。

0x00 Meta Table & Mate Methods

在 Lua 中,可以通过定制 table 的元表以及元方法来改变 table 的行为,例如实现运算符重载等。其中有一个很有用的元方法: __index

当访问一个 table 的属性时它是这样工作的:

  1. table 中是否有这个属性?Y) 返回该属性的值;
  2. 元表是否为空?Y)返回 nil;
  3. __index 是否为空? Y)返回 nil
  4. __index 是一个 function?Y)调用 __index(table, key) 并返回结果
  5. __index 是一个 table?Y)递归从 __index 指向的表中查询该属性
foo = {}
foo.bar -- nil

bar = {}
setmetatable(bar, {
    __index = {
        baz = 1
    }
})
bar.baz -- 1

递归查表这个行为是不是很像 javascript 的原型链!如果把 Lua 的 table 比作是 Javascript 的 Object,那么元表中的 __index 就相当于 Object 的 __proto__。与 Javascript 不同的是, Lua 中的 function 与 table 是两个不同的类型;而 Javascript 中的 Function 同时也是(is-a) Object 。

0x01 Class

作为一个类,它应该有一个构造函数(constructor),并且能实例化对象(new)。这些对象能使用类提供的方法:

Foo = {}
Foo.__index = Foo

function Foo:ctor(str)
    self.str = str
end

function Foo:say()
    print(self.str)
end

function Foo.new(...)
    local instance = {}
    setmetatable(instance, Foo)
    instance:ctor(...)
    return instance
end

foo = Foo.new("bar")
foo:say() -- output: bar

Foo.new 方法为实例创建了个空表,并将 Foo 作为它的元表。借由 Foo.__index = Foo 这个实例能够访问到 Foo 中定义的方法。

根据这个方法,我们可以创建一个类工厂:

function class()
    local cls = {}
    cls.__index = cls

    -- a dumb ctor
    function cls:ctor() end

    function cls.new(...)
        local instance = setmetatable({}, cls)
        instance:ctor(...)
        return instance
    end

    return cls
end

然后就可以用这个类工厂批量创建类了:

Foo = class()

-- override ctor
function Foo:ctor(str)
    self.str = str
end

function Foo:say()
    print(self.str)
end

foo = Foo.new("bar")
foo:say() -- output: bar

0x02 Inheritance

接下来我们可以给类增加继承的功能。所谓继承,即子类能够扩展超类,其实例能够使用该类以及超类的方法。

本质上就是使用元表的递归查找,不断向上检索属性和方法:

function class(super)
    local cls
    if super == nil then
        -- table with a dumb ctor
        cls = { ctor = function() end }
    else
        cls = setmetatable({}, super)
        cls.super = super
    end

    cls.__index = cls

    function cls.new(...)
        local instance = setmetatable({}, cls)
        instance:ctor(...)
        return instance
    end

    return cls
end

试试效果:

Foo = class()

-- override ctor
function Foo:ctor(str)
    self.str = str
end

function Foo:say()
    print(self.str)
end

Bar = class(Foo)

function Bar:sayMore(str)
    self.super.say(self) -- invoke method from super class
    print(str)
end

bar = Bar.new("bar")
bar:sayMore("bar") -- output: bar bar

0x03 is-a

为了让继承更有说服力,我们可以参考 javascript 的 instanceof 运算符 原理实现 is-a 方法,用来检测实例与类的关系——若一个类是一个 table 的元表,则这个 table 是这个类的实例,并向上递归:

function isa(instance, Class)
    local metatable = getmetatable(instance)
    while metatable ~= nil do
        if metatable == Class then
            return true
        else
            metatable = getmetatable(metatable)
        end
    end

    return false
end

测试:

Foo = class()
foo = Foo.new()
print(isa(foo, Foo)) -- true

Bar = class(Foo)
bar = Bar.new()
print(isa(bar, Bar)) -- true
print(isa(bar, Foo)) -- true

Baz = class(Foo)
baz = Baz.new()
print(isa(baz, Bar)) -- false
print(isa(baz, Foo)) -- true

0x04 More

说到面向对象,除了继承,还得提封装和多态。在 Lua 中,使用闭包(Closure)很容易就可以实现封装。而上面提到的 is-a 方法,对多态提供了很好的支持。作为一个轻量级脚本语言,虽然不能面面具到,但已经有了实现 OOP 的基础。

理解 iOS 开发证书

正式工作两年了,就在上个月,我换了份新工作,也换了个环境。从 Cocos2d-x + js 转战 Unity3d + ulua,于是又有很多东西要重头学起。在小创业团队跟大公司还是有很多不一样的,需要接触并掌握更多的东西,例如参与产品发布之类的环节。所以近来一段时间研究了一下苹果开发者计划,理解了一下它的工作机制。

0x00 Apple Developer Program

为了能够让自己开发的 App 发布到 App Store,苹果开放了 iOS 开发者计划 Apple Developer Program 。这个计划有四种类型:个人、公司、企业及高校。前两者的授权费用都是 $99 一年,适合个人或团队开发者。区别是,个人开发者以自己的名义发布 App,而公司则是以团体名义发布 App。此外公司是以法人(Agent)帐号注册 iOS 开发者计划,并且可以邀请其它开发者加入团队。其它人加入后,可以共同进行 App 开发。更多的细节可以参考 苹果开发者账号那些事儿 [1] [2] [3]

这里我并不记录这些帐号及证书的申请过程,有兴趣可以细读上面的链接。我更感兴趣的是这些东西是如何协作起来的。

0x01 Certificates

Development Certificate

当你拥有一个开发者帐号后,你需要申请一个「开发者证书」。根据证书创建的流程,首先需要在 Keychain Access 生成一个「证书签名请求」文件提交到苹果开发者中心,然后苹果开发者中心会生成一个「开发者证书」,下载这个证书并导入到 Keychain 之后,你就能将 APP 运行到真机上进行测试了。

可以简单将这个证书视为你参与开发者计划的证明,但是它是如何工作的呢?这是一个非对称加密体系的过程,如果你不了解 RSA,可以先看看这几篇介绍 RSA算法原理 [1] [2] 以及 现代密码学:
RSA加密 [1]
[2] [3]

当 Keychain 创建证书签名请求文件(CertificateSigningRequest.certSigningRequest)的时候,实际上在本地创建了一个私钥,然后将你填写的信息(邮件地址和用户名)带上公钥生成 CSR 文件(注意 CSR 并不包含私钥)。接下来将这个 CSR 文件提交到苹果开发者中心——其本质是一个证书颁发机构(CA),它会将 CSR 包含的信息与你的开发者帐号关联起来用 CA 的私钥签名并生成证书文件。这样一来,任何具有 CA 公钥的一方(例如未越狱的 iOS 设备)就能验证你所签名的 APP 确实是来自一个合法的开发者。

BTW:非苹果开发者在越狱设备调试 APP,正是利用自签名证书并替换或禁用设备上的CA公钥来绕开认证。

Distribution Certificate

当 APP 开发完成后,需要提交到 App Store 进行审核,或者打 ipa 包(ad hoc)在公司内部进行测试。这时候需要用「发行证书」对 APP 进行签名。其原理与「开发者证书」相似,证书的分离能让开发与部署在不同的环境下进行,对公司来说也更好管理不同开发人员的职能。

注意:备份好私钥及 CSR 文件,以便在证书过期或不同设备间协同开发/发布 APP 的时候使用。

0x02 APP IDs

每一个 APP 都有一个唯一的 ID 标识,一般格式为 com.company.product 。这一标识与 xcode 中的 Bundle Id 对应。在开发者中心的 Identifiers 频道可以对单个或多个 APP 设置相应的权限,例如启用/关闭「推送(Push Notifications)」功能。

Push Notifications

推送功能是很常用的一个功能。有趣的是,启用这个功能后也需要申请证书才能使用这个功能。苹果通过同样的机制来确保推送的信息来自合法的私钥持有方。如果使用第三方SDK,他们会要求你将私钥提提交给他们。一个 APP 可以有多个推送证书,且随时可以撤消该证书。

0x03 Devices

每个开发者帐户能够添加 99 台用于测试的 iOS 设备。只需要将设备的 Unique Device Identifier(UDID,可在 xcode > devices 中查看)添加到列表中即可,在最新版本的 xcode 中,第一次使用新设备时会自动完成添加。

0x04 Provisioning Profiles

配置文件用于管理特定的设备能否运行指定的证书签名的 APP 。同样分为 Development 和 Distribution 两种类型,xcode 会自动管理配置文件,并安装到开发设备上。但在国内常常会因为网络问题,xcode 没能及时地与苹果开发者中心同步配置文件,导致各种异常,这时可以到 xcode > preferences... > accounts 进行手工刷新。

其它参考资料

  1. iOS 开发流程笔记
  2. HTTPS 安全: 安全术语简介

使用 PureMVC 和 Cocos2d-js 构建游戏项目 II

去年九月份写了一篇《使用 PureMVC 和 Cocos2d-js 构建游戏项目》,阐述了在 cocos2d-js 中使用 PureMVC 框架的想法,并在项目中试水后感觉效果也比较理想。但是整个框架中令我最不满意的两点就是直接在 Mediator 中处理层级和场景栈以及保存状态。

对于前者,官方的《PureMVC 最佳实践》一书里曾提到:

Page.28 虽然 Mediator 可以从 View 获取其他的 Mediator, 通过 API 访问、操作它们。但这样是很不好的, 它会导致 View 下成员的相互依赖, 这违反了“改变一个不影响其他”的目的。

之前设计的框架中处理层级的逻辑是在基类(NestMediator)中实现的。框架中的所有界面相关的 Mediator 子类都继承它。使用的操作也只涉及基类开放的 API 。而各个子类之间不再有其它的关联。但即使是这样,心里还是感觉不舒坦。

对于后者,使用 Mediator 保存状态以便出栈的时候恢复场景使用——这是当时感觉比较有创意的想法。但是后来仔细想想状态应该是属于 Model 层维护的东西才对啊。放在栈里的应该是数据,而不是 Mediator 。

当前的项目越做越复杂,一直找不到一个合适的契机进行重构,所以只能寄希望于新的项目了。正好近期公司有其它项目组准备启动新的游戏项目,也打算使用 Cocos2d-js + PureMVC 来进行开发,希望我能够提供个框架的 DEMO 。于是我正好借此机会将新想法融入到新的框架中。

在新的框架中,我增加了 Context(上下文)的数据域,用于储放状态和层级关系。每一个 Context 对应一个 Mediator ,可用于恢复场景;并且使用 ContextProxy 维护根 Context 列表也就是所谓的场景栈。然后将原来 Mediator 处理层级的逻辑全部转移到 Command 中,这样一来,Mediator 如释重负,可以专心于处理 View 层的事情了。

项目的结构我在上一篇《cococs2d-jsList》的末尾彩蛋中给出。具体的思路,等我另开一篇 part 3 再详述吧。

2015/08/25 updated:

后续文章 使用 PureMVC 构建游戏项目 III
后续文章 使用 PureMVC 构建游戏项目 IV

cocos2d-jsList

cocos2d-jsList 是一个用来自动生成用于 cocos2d-js/cocos2d-html5 代码列表(jsList)的小工具。jsList 位于 project.json 中,用来指明 cocos2d-js 项目代码的加载顺序,详情可见官方文档

由于众所周知的原因,要处理 cocos2d-js 的 js 依赖并不容易,很多项目依然使用最原始的方式进行开发,而不是使用模块化管理。所以还是需要手动维护 jsList 列表来处理代码依赖(例如类继承)。

为了减少一些工作量和手动处理带来的失误,于是我写了 cocos2d-jsList 这个小工具。

项目地址: https://github.com/mutoo/cocos2d-jsList

安装 Install

jslist 使用 node.js 编写,可以很方便地使用 npm 进行安装:

$ npm install jslist -g

由于 jslist 是一个命令行工具,所以需要使用 -g 选项安装到全局目录。

使用 Usage

首先需要在 project.json 里面添加 jsListOrder 字段

{
  ...
  jsListOrder: [
    "src/**/*.js"
  ]
  ...
}

jslist 使用 node-glob 工具来进行文件名匹配。使用 *.js 可以匹配指定目录下的 js 文件,而 **/*.js 可以遍历其子目录。具体的用法可以参见 node-glob 官方文档。

然后进入项目根目录,执行 jslist 即可获得 jsList 列表:

$ cd /path/to/your/cocos2d-js/project
$ jslist

更多的用法请参见:

$ jslist --help

进阶 Advance

可以按照需要的顺序将文件列出:

jsListOrder: [
  "src/**/*.js"
  "src/resource.js",
  "src/app.js"
]

以上配置可以保证 src/app.js 始终在列表的最后,跟在 src/resource.js 后面。

这对于处理依赖很有帮助,例如可以将 /lib/ 目录中的代码全部前置,或者将一个超类提前到同目录的其它文件之前:

jsListOrder: [
  "src/lib/**/*.js",
  "src/controller/SuperController.js",
  "src/controller/**/*.js",
  "src/model/**/*.js",
  "src/view/**/*.js",
  "src/**/*.js",
]

示例 Example

{
    "project_type": "javascript",
    "debugMode": 1,
    "showFPS": true,
    "frameRate": 60,
    "id": "gameCanvas",
    "renderMode": 0,
    "engineDir": "frameworks/cocos2d-html5",
    "modules": [
        "cocos2d",
        "cocostudio"
    ],
    "jsListOrder": [
        "src/lib/puremvc-1.0.1.js",
        "src/lib/**/*.js",
        "src/utils/**/*.js",
        "src/const.js",
        "src/lang.js",
        "src/controller/command/context/ContextCommand.js",
        "src/controller/**/*.js",
        "src/model/**/*.js",
        "src/view/mediator/context/ContextMediator.js",
        "src/view/**/*.js",
        "src/**/*.js",
        "src/resource.js",
        "src/app.js"
    ],
    "jsList": [
        "src/lib/puremvc-1.0.1.js",
        "src/lib/puremvc-statemachine.1.0.js",
        "src/lib/underscore.js",
        "src/utils/helper.js",
        "src/const.js",
        "src/lang.js",
        "src/controller/command/context/ContextCommand.js",
        "src/controller/command/CheckUserAndLoginCommand.js",
        "src/controller/command/ConnectToServerCommand.js",
        "src/controller/command/context/AddChildContextCommand.js",
        "src/controller/command/context/AddNextContextCommand.js",
        "src/controller/command/context/BackContextCommand.js",
        "src/controller/command/context/CloseContextCommand.js",
        "src/controller/command/context/RunSceneWithContextCommand.js",
        "src/controller/command/startup/InjectFSMCommand.js",
        "src/controller/command/startup/PrepControllerCommand.js",
        "src/controller/command/startup/PrepModelCommand.js",
        "src/controller/command/startup/PrepViewCommand.js",
        "src/controller/command/startup/StartupCommand.js",
        "src/model/proxy/context/ContextProxy.js",
        "src/model/proxy/context/GroupProxy.js",
        "src/model/proxy/ServersProxy.js",
        "src/model/proxy/UserProxy.js",
        "src/model/vo/context/Context.js",
        "src/model/vo/context/Group.js",
        "src/model/vo/Server.js",
        "src/model/vo/Token.js",
        "src/model/vo/User.js",
        "src/view/mediator/context/ContextMediator.js",
        "src/view/mediator/ContextTestMediator.js",
        "src/view/mediator/DirectorMediator.js",
        "src/view/mediator/LoginMediator.js",
        "src/view/mediator/MainMediator.js",
        "src/view/mediator/NotificationMediator.js",
        "src/view/mediator/ServersMediator.js",
        "src/view/ui/ContextTestLayer.js",
        "src/view/ui/LoginLayer.js",
        "src/view/ui/MainLayer.js",
        "src/view/ui/NotificationLayer.js",
        "src/view/ui/ServersLayer.js",
        "src/resource.js",
        "src/app.js"
    ]
}

手游登录服务器构架

之前的日志中,我分析了手游客户端登录模块的开发流程,指出了一些开发中常见的错误,以及对应的处理方法。随着游戏不断成长,玩家以及接入的第三方平台也会越来越多。游戏的服务器数量必然会随之增长。于是需要一套合理的架构来承载这些变化。

简单服务器

在游戏刚开始运营时,由于玩家不多,可能服务器也只有一个。玩家登录游戏后只需要简单验证一下,即可接入游戏服务器。

login_simple.png

服务器增加

随着玩家人数的增加,运营开始增设服务器。于是客户端需要有一个服务器列表供玩家选择。于是问题来了,玩家是先从列表中选择要进入服务器,再登录帐号;还是先登录帐号,再从列表中选择服务器?

login_simple_multi.png

有的团队选择前者,于是玩家打开游戏的时候,先加载服务器列表,然后从列表中选择要登录的服务器,并以上述简单架构的方式登录游戏。每个服务器独立对玩家的帐号进行验证。对于接下来要应对的需求变化,这样的简单架构可能就不足以应付了。

测试放行

运营要求对于新开的服务器,测试人员可以优先进入;而普通玩家暂时看不到,也无法进入。

使用上面所述的构架,在客户端没有登录的情况下,你是无法知道玩家是否具有测试权限,于是无法确定服务器列表中是否显示新开服务器。在不改变架构的情况下,有以下几种解决方案:

  1. 为测试人员提供不同的客户端,使他们加载不同的服务器列表。这种方式维护起来并不方便,也无法灵活地赋予(或取消)普通玩家测试权限;
    login_test_client.png

  2. 停服维护,期间所有玩家无法进入游戏,除非该玩家具有测试权限。这种方式造成的体验非常不好,如果维护时间过长,必然流失很多玩家;如果选择在线玩家较少的夜间进行维护,对开发者而言又十分辛苦;

  3. 客户端选择其中一个服务器登录,然后验证其测试权限,然后令其重新登录并给出包含新开服务器的列表。这种拐弯抹角的方式实在称不上是一种解决方案,但我确实见识过——毕竟不用停服维护了嘛。

跳坑

那么要如何修改架构使其更加合理呢?其实很简单,只要把登录流程反转过来就行了。客户端先登录帐号,根据其帐号具有的权限,显示不同的服务器列表。然后玩家选择其中一个服务器进入游戏。

登录服务器

login_server.png

为了实现这样的架构,我们需要把登录认证这个过程移植到一个专用服务器上。游戏客户端运行后,先连接至登录服务器。并提交相应的平台、登录帐号、SDK TOKEN 等信息。然后登录服务器对 SDK TOKEN 进行确认后,根据帐号权限返回相应的游戏公告、服务器列表、LOGIN TOKEN 等信息。

之后玩家使用获取到的 LOGIN TOKEN 进入所选择的游戏服务器即可。游戏服务器不需要再对玩家的登录信息和权限进行验证。取而代之的是对 LOGIN TOKEN 进行解密验证,获取里面的 UID 以及过期时间,确信无误后同意玩家进入游戏。

有了登录服务器,可以扩展非常多的功能,加测试权限或者封号什么的,再也不怕运营提需求了~

updated 20150821: 手机网游的用户中心设计