定制 UnityAppController

开发 Unity3d 手机游戏的时候,不免要和第三方 SDK 打交道。于是总是需要实现自己的 AppController 来维护 SDK 的生命周期。

Unity3d 提供了一套插件机制,可以很方便地在项目中使用自己的 CustomAppController 继承并重写默认的 UnityAppController 的方法。

0x00 CustomAppController

在 Unity 插件目录下创建以下文件:
/path/to/unity/project/Assets/Plugins/iOS/CustomAppController.mm

注意,文件名必须是 ___AppController,前缀可自选,但不能省略;否则在 Build 项目的时候,会被移动到错误的目录中去。

CustomAppController.h 头文件是可选的,不过通常直接把 @interface 直接放在 .mm 文件里就好。下面以微信 SDK 为例:

#import "UnityAppController.h"
#import "WXApi.h"

@interface CustomAppController : UnityAppController < WXApiDelegate >
@end

IMPL_APP_CONTROLLER_SUBCLASS (CustomAppController)

@implementation CustomAppController

- (BOOL)application:(UIApplication*)application didFinishLaunchingWithOptions:(NSDictionary*)launchOptions
{
    [super application:application didFinishLaunchingWithOptions:launchOptions];

    [WXApi registerApp: @"_________"];
    
    return YES;
}

- (BOOL)application:(UIApplication*)application openURL:(NSURL*)url sourceApplication:(NSString*)sourceApplication annotation:(id)annotation
{
    return [WXApi handleOpenURL:url delegate:self];
}

- (BOOL)application:(UIApplication *)application handleOpenURL:(NSURL *)url
{
    return [WXApi handleOpenURL:url delegate:self];
}

- (void)onResp:(BaseResp *)resp
{
    // do something
}

- (void)onReq:(BaseReq *)req
{
    // do something
}

@end

在 Build iOS Project 的时候,Unity 会自动把 CustomAppController.mm 复制到 /path/to/project/Libraries/CustomAppController.mm

而原来的 UnityAppController.mm 则在 /path/to/project/Classes/UnityAppController.mm

那么 Unity 是如何知道要使用我们定制的 CustomAppController 而不是使用默认的 UnityAppController 呢?

0x01 IMPL_APP_CONTROLLER_SUBCLASS

很多文章在提到继承 UnityAppController 后,需要找到 /path/to/project/Classes/main.mm 里面的:

const char* AppControllerClassName = "UnityAppController";

将其修改为:

const char* AppControllerClassName = "CustomAppController";

从而使 Unity 在启动的时候使用我们制定的 CustomAppController 类。

这样一来,每次 Build 项目都需要手动去修改这个常量,岂不是自找麻烦。其实完全可以利用 Objective-c 的特性来自动完成这个操作。

注意到 UnityAppController.h 里面有这样一个宏:

#define IMPL_APP_CONTROLLER_SUBCLASS(ClassName) \
@interface ClassName(OverrideAppDelegate)       \
{                                               \
}                                               \
+(void)load;                                    \
@end                                            \
@implementation ClassName(OverrideAppDelegate)  \
+(void)load                                     \
{                                               \
    extern const char* AppControllerClassName;  \
    AppControllerClassName = #ClassName;        \
}                                               \
@end

将这个宏加到 CustomAppController.mm 中,即可实现自动设置 AppControllerClassName :

IMPL_APP_CONTROLLER_SUBCLASS (CustomAppController)

是不是很神奇呢!IMPL_APP_CONTROLLER_SUBCLASS 使用了两个 Objective-C 的特性,一是 category ,用来给已有的类扩展新的方法;二是 +(void)load 静态方法,它会在运行时 CustomAppController 类被加载到内存中时触发,这个时间点比 int main() 函数还要早,所以能够提前“篡改” AppControllerClassName,达到我们的目的。

参考资料

UnityのiOSでAppDelegateに処理を追加する

使用 PureMVC 构建游戏项目 Part IV 之场景管理

许多游戏引擎已经提供了原生的场景管理功能,例如 Cocos2d-x 的 CCDirector.replaceScene() 或者 Unity3d 的 Application.loadLevel() 。场景切换的本质是将原有的对象删除并从内存中释放,腾出空间然后加载新的场景资源。但这并不能满足当下的一些需求,简单的从将一个场景销毁,进入下一个场景,而想要点击返回的时候,回退到原来的界面,并不是简单重新加载一下原来的界面就可以了,还需要还原当时现场的一些关键状态(详细的需求见 Part I)。

所谓的关键状态就是,例如:
你在进入装备界面,默认显示所有装备(type = all),然后你点击界面上的分类按钮,按类型B(type = B)进行筛选。然后你从装备界面跳转到关卡界面(准备去相应的关卡打装备),打完后你想回到装备界面时,它还按类型 B 而不是默认 all 进行筛选。那么 type 就是一个关键状态。

在手机游戏中,场景指的是那些独占整个屏幕的模块;此外还有一些模块是浮动于场景之间的,类似于层,可以弹出和关闭。在 Part III 的 Mediator 部分,我描述了两种类型的视图组件(ViewComponent):一种是 Scene;另一种是 Layer。在 Part I 中,我将这两种类型的 View 区别开来:NestMediator 和 SubNestMediator。由于我觉得这些 Mediators 是一种嵌套关系(类似 CCNode),所以在做场景恢复的时候,我认为等 Mediator 创建并加载 ViewComponent 后遍历子节点,逐层恢复就可以了。但这导致 Part II 中提到的 Mediators 相互引用,并且有大量场景切换和还原的逻辑被放在 NestMediator 中。

后来我重新审视了这个设计,发现虽然可行,但有太多不合理的地方。现将新的方案整理如下:将状态和场景树转移到 Contexts 中储存,使用 Commands 来处理 Mediator 和 ViewComponent 的注册与销毁,而场景实际切换过程交给 SceneManager 处理。

0x00 Context & ContextProxy

Context

Part II 中我提出了使用 Context 来储存场景状态以及嵌套关系:Context 描述了一个场景或者层使用哪个 Mediator 和 ViewComponent 创建节点,包含哪些关键状态(data),并实现了简单的嵌套关系。

Context = class("Context")

function Context:ctor(data)
    self.mediatorClass = data.mediatorClass
    self.viewComponentClass = data.viewComponentClass
    self.data = {}

    self.parent = nil
    self.children = {}
end

function Context:addChild(context)
    assert(isa(context, Context), "should be an instance of Context")
    assert(context.parent == nil, "context already has parent")
    
    context.parent = self;
    table.insert(self.children, context)
end

function Context:removeChild(context)
    assert(isa(context, Context), "should be an instance of Context")

    for i, v in ipairs(self.children) do
        if v == context then
            context.parent = nil
            return table.remove(self.children, i)
        end
    end

    return nil
end

用以下代码可以描述「打开一个主场景,带有一个弹出的邮件界面」:

local mainContext = Context.new({
    mediatorClass = MainMediator,
    viewComponentClass = MainScene
})

local mailboxContext = Context.new({
    mediatorClass = MailboxMediator,
    viewComponentClass = MailboxLayer
})

mainContext:addChild(mailboxContext)

game.facade:sendNotification(GAME.LOAD_SCENE, mainContext)

ContextProxy

每打开一个场景,就将场景树的根结点放到 ContextProxy 中,当点击返回按钮的时候,将当前场景 Context 出栈,再取出下一个 Context 重新加载,即可以实现返回场景。所以我们需要实现一个栈式结构的 Proxy 来维护场景栈。

ContextProxy = class('ContextProxy', Proxy)

function ContextProxy:getCurrentContext()
    return self.data[#self.data]
end

function ContextProxy:pushContext(context)
   table.insert(self.data, context)
end

function ContextProxy:popContext()
    return table.remove(self.data)
end

function ContextProxy:cleanContext()
    self.data = {}
end

function ContextProxy:getContextCount()
    return #self.data
end

0x01 GameMediator

游戏启动后,会注册一个 GameMediator 作为场景的控制中枢,实际上它是一个工厂,根据场景的名字创建 Context 实例并把相应的 Mediator 与 ViewComponent 关联起来:

local GameMediator = class('GameMediator', Mediator)

function GameMediator:listNotificationInterests()
    return {
        GAME.GO_TO_SCENE
    }
end

function GameMediator:handleNotification(note)
    local name = note:getName()
    local data = note:getBody()

    local context = Context.new()

    -- load context data
    for k, v in pair(data)
        context.data[k] = v
    end

    if name == GAME.GO_TO_SCENE then
        local type = note:getType()

        if type == SCENE.MAIN then
            context.mediatorClass = MainMediator
            context.viewComponentClass = MainScene

        elseif type == SCENE.EQUIPMENT then
            context.mediatorClass = EquipmentMediator
            context.viewComponentClass = EquipmentScene

        -- elseif type == ... then
        --     context.mediatorClass = ...
        --     context.viewComponentClass = ...
        --
        -- ...

        end
    end

    self:sendNotification(GAME.LOAD_SCENE, context)
end

在需要切换场景的时候,广播以下消息即可:

game.facade:sendNotification(GAME.GO_TO_SCENE, {
    type = "all"
}, SCENE.EQUIPMENT)

其中第二个参数会被合并到 context.data 中,作为状态被传递到 Mediator 使用,使用 context.data 在场景之间传递信息非常方便。在 EquipmentMediator 要使用 context.data 中的信息只要访问 self.contextData 即可。最后会广播并调用 LoadSceneCommand 来创建和切场景。

0x02 Commands

控制场景的命令主要有这几个:

  • LoadSceneCommand - 清空当前场景树,并加载新场景树
  • LoadLayersCommand - (递归)加载除根节点(场景)外的所有子节点(层)
  • RemoveLayersCommand - (递归)移除根节点(场景)外的所有子节点(层)
  • BackSceneCommand - 返回前一场景

与之对应的消息如下:

  • GAME.LOAD_SCENE
  • GAME.LOAD_LAYERS
  • GAME.REMOVE_LAYERS
  • GAME.GO_BACK

LoadSceneCommand

LoadSceneCommand 只处理 Mediators 和 ViewComponents 的创建和销毁。而把 ViewComponents 的切换过程交给 SceneManager 处理,并以异步的方式串连起来。切换场景的过程如下:

  1. 首先是取出当前场景 Context,按层序遍历场景树,然后逆序逐个删除,由于删除层结点的过程有可能存在动画效果,所以这个过程是异步的。这部分的代码由 RemoveLayersCommand 配合 SceneManager 实现,最终留下当前场景的根结点;
  2. 场景的切换可以有不同的方式(详见 SceneManager 部分),以其中一种方式载入下一个场景的根节点;
  3. 对新场景的根结点(context)进行层序遍历,逐一还原每一个层结点,由于加载层结点的过程可能存在动画效果,所以这个过程也是异步的。这部分的代码由 LoadLayersCommand 配合 SceneManager 实现;
  4. 切换场景过程完成。
LoadSceneCommand = class('LoadSceneCommand', SimpleCommand)

function LoadSceneCommand:execute(note)
    local data = note:getBody()
    local context = data.context
    assert(isa(context, Context), "should be an instance of Context")

    local viewComponent = context.viewComponentClass.new()
    assert(isa(viewComponent, BaseView), "should be an instance of BaseView: " .. viewComponent.__cname)

    local function onTransFinish(viewComponent)
        local mediator = context.mediatorClass.new(viewComponent)
        mediator:setContextData(context.data)
        self.facade:registerMediator(mediator)

        self:sendNotification(GAME.LOAD_LAYERS, {
            context = context
        })
    end

    local contextProxy = getProxy(ContextProxy)
    local fromViewComponent;

    local function nextScene()
        if context.transType == Context.TRANS_TYPE.ONE_BY_ONE then
            SceneManager.transOneByOne(fromViewComponent, viewComponent, onTransFinish)
        else
            SceneManager.transCross(fromViewComponent, viewComponent, onTransFinish)
        end

        if context.cleanStack then
            contextProxy:cleanContext()
        end

        contextProxy:pushContext(context)
    end

    local function onRemoved()
        function()
            local prevMediator = self.facade:removeMediator(prevContext.mediator.__cname)
            fromViewComponent = prevMediator:getViewComponent()
            nextScene()
        end
    end

    local prevContext = data.prevContext or contextProxy:getCurrentContext()
    if prevContext ~= nil then
        assert(isa(prevContext, Context), "should be an instance of Context")

        self:sendNotification(GAME.REMOVE_LAYERS, {
            context = prevContext,
            onFinish = onRemoved
        })
    else
        nextScene()
    end
end

RemoveLayersCommand

RemoveLayersCommand 有两个参数,一个是要进行移除的 context 对象。当 context 是一个场景结点时(context.parent == nil),只对其子结点进行逆层序移除;否则连同 context 对应的层结点一起移除。另一个参数是一个回调函数,在清除完成后被调用。

local RemoveLayersCommand = class('RemoveLayersCommand', SimpleCommand)

function RemoveLayersCommand:execute(note)
    local data = note:getBody()
    local context = data.context
    assert(isa(context, Context), "should be an instance of Context")

    -- Breadth-First-Search
    local open = { context }
    local close = {}
    while #open > 0 do
        local context = table.remove(open, 1) -- FIFO

        table.insert(close, context)

        for _, v in ipairs(context.children) do
            table.insert(open, v)
        end
    end

    if context.parent == nil then
        -- do not remove root context
        table.remove(close, 1)
    else
        context.parent:removeChild(context)
    end

    -- remove context tree
    local removeState, status, iter
    removeState = coroutine.create(function()
        while #close > 1 do
            local context = table.remove(close) -- LIFO
            local subMediator = self.facade:removeMediator(context.mediator.__cname)

            local removed = false
            local function onRemoved()
                coroutine.resume(removeState)
                removed = true
            end

            SceneManager.remove(subMediator:getViewComponent(), onRemoved)
            if not removed then coroutine.yield() end
        end

        -- do the callback
        if data.callback then data.callback() end
    end)

    -- start remove
    coroutine.resume(removeState)    
end

这里使用 Lua 的协程来组织异步场景卸载,如果用 javascript 的话,可以考虑 async.js 或者将来使用 es6 提供的 generator 。

LoadLayersCommand

LoadLayersCommand 有三个参数,一个是要进行层序加载的 context 对象,一个是可选的 parentContext 。当 context 是场景结点时(parentContext = nil)只对其子结点进行载入;否则连同 context 一起加载。另外还有个是完成时的回调函数。

local LoadLayersCommand = class('LoadLayersCommand', SimpleCommand)

function LoadLayersCommand:execute(note)
    local data = note:getBody()
    local context = data.context
    assert(isa(context, Context), "should be an instance of Context")

    local open = {}

    local restoreLayers, status, iter
    restoreLayers = coroutine.create(function()
        -- restore context tree

        while #open > 0 do
            local context = table.remove(open, 1)
            for _, v in ipairs(context.children) do
                table.insert(open, v)
            end

            local parentContext = context.parent
            local parentMediator = self.facade:retrieveMediator(parentContext.mediator.__cname)
            local parentViewComponent = parentMediator:getViewComponent()

            local viewComponent = context.viewComponent.New()
            local finish = false
            local function onFinish()
                local mediator = context.mediator.New(viewComponent)
                mediator:setContextData(context.data)
                self.facade:registerMediator(mediator)
                coroutine.resume(restoreLayers)
                finish = true
            end

            SceneManager.overlay(parentViewComponent, viewComponent, onFinish)
            if not finish then coroutine.yield() end
        end

        if data.callback then data.callback() end
    end)

    local parentContext = data.parentContext
    if parentContext ~= nil then
        assert(isa(parentContext, Context), "should be an instance of Context")

        if parentContext:getContextByMediator(context.mediator) then
            print("Mediator already exist: " .. context.mediator.__cname)
            return
        end

        table.insert(open, context)
        parentContext:addChild(context)
    else
        for _, v in ipairs(context.children) do
            table.insert(open, v)
        end
    end

    -- start restore
    coroutine.resume(restoreLayers)    
end

BackSceneCommand

在 ContextProxy 部分,我们说到「将当前场景 Context 出栈,再取出下一个 Context 重新加载,即可以实现返回场景。」,除此之外,我们还可以在返回时传递一些 context.data 。这便是 BackSceneCommand 所要做的全部事情:

local BackSceneCommand = class('BackSceneCommand', SimpleCommand)

function BackSceneCommand:execute(note)
    local data = note:getBody()

    local contextProxy = getProxy(ContextProxy)
    if contextProxy:getContextCount() > 1 then
        local currentContext = contextProxy:popContext()
        local prevContext = contextProxy:popContext()
        prevContext:extendData(data)

        self:sendNotification(GAME.LOAD_SCENE, {
            prevContext = currentContext,
            context = prevContext
        })
    end
end

0x03 Base View Component

在 Commands 中随处可见 SceneManager 的踪影。但是在介绍 SceneManager 之前,首先要了解一下基本的 ViewComponent 的生命周期是什么样的,然后再把两个 ViewComponent 的生命周期重叠或连接,就可以表现场景切换。

一般而言,我们会把一个模块做成一个场景或层,使用 UI 编辑器将其打包成一个独立的资源。然后将它作为 ViewComponent 的一个资源载入使用。所以我们并不需要在 ViewComponent 层面区分场景或层。

Part I 的设计不同的是,现在的 ViewComponent 并不是一个引擎中的结点(Unity3d 的 GameObject 或者 Cocos2d 的 CCNode)。引擎中的结点只是 ViewComponent 的一个引用。通过这个引用,我们需要维护这个资源的生命周期:

  1. load() 开始加载资源(方法)
  2. LOADING 加载进度(事件)
  3. ON_LOADED 加载完成(事件)
  4. attach() 绑定父结点(方法)
  5. init() 初始化(方法)
  6. enter() 进场并播放进场动画(方法)
  7. DID_ENTER 进场完成(事件)
  8. onEnter() 进场完成后回调(方法)
  9. exit() 退场并播放退场动画(方法)
  10. onExit() 退场前回调(方法)
  11. DID_EXIT 退场完成(事件)
  12. detach() 从父结点移除(方法)

我们需要一种方式,将这些事件推送给 SceneManager,这样当两个场景进行切换的时候,就能自如控制。在 Part III 中的 View 部分,我提到的 LuaNotify 或 emitter.js 就是用来做这件事情的。以下是 BaseViewComponent 的参考实现:

BaseViewComponent = class("BaseViewComponent")
local Event = require('Framework.notify.event')

-- 用于场景切换的事件
BaseViewComponent.LOADED = "BaseViewComponent:LOADED"
BaseViewComponent.DID_ENTER = "BaseViewComponent:DID_ENTER"
BaseViewComponent.DID_EXIT = "BaseViewComponent:DID_EXIT"

-- 返回事件
BaseViewComponent.ON_BACK = "BaseViewComponent:ON_BACK"

-- 层关闭事件
BaseViewComponent.ON_CLOSE = "BaseViewComponent:ON_CLOSE"

function BaseViewComponent:Ctor()
    self.event = Event.New() -- 事件
    self._isLoaded = false -- 是否加载完成
    self._go = nil -- game object 引用
    self._tf = nil -- transform 引用
end

-- 获取 UI 名称,由子类实现
function BaseViewComponent:getUIName()
    return nil
end

-- 开始加载
function BaseViewComponent:load()
    -- 获取 UI 名称
    local uiName = self:getUIName()
    if uiName ~= nil then
        self:LoadUI(uiName, "OnUILoaded")
    else
        self:OnUILoaded()
    end
end

-- 是否加载完成
function BaseViewComponent:isLoaded()
    return self._isLoaded
end

-- UI 加载完成后被调用
-- @param go 来自 Unity3d 的 Game Object
function BaseViewComponent:OnUILoaded(go)

    -- 加载完成
    self._go = go
    self._tf = go and go.transform

    self._isLoaded = true

    -- 执行一些初始化工作
    self:init()

    -- 通知加载完成
    self.event:emit(BaseViewComponent.LOADED)
end

-- 由子类实现的一些初始工作
function BaseViewComponent:init() end

-- 加载完成后进入场景
function BaseViewComponent:enter()

    -- 子类可以覆盖该方法,播放一段动画
    -- 然后通知完成入场
    self.event:emit(BaseViewComponent.DID_ENTER)

    -- 通知后会执行 mediator 的 onRegister

    -- 入场后执行的一些操作
    self:onEnter()
end

-- 由子类实现的入场后操作
function BaseViewComponent:onEnter() end

-- 由子类实现的出场前操作
function BaseViewComponent:onExit() end

-- 退出场景
function BaseViewComponent:exit()

    -- 执行一些退场前操作
    self:onExit()

    -- 子类可以覆盖该方法,播放一段动画
    -- 然后通知退场完成
    self.event:emit(BaseViewComponent.DID_EXIT)

    self:detach()
end

-- 添加到父结点
function BaseViewComponent:attach(parent)
    if self._tf ~= nil and parent._tf ~= nil then
        self._tf:SetParent(parent._tf, false)
    end
end

-- 从父结点删除
function BaseViewComponent:detach(parent)

    -- 销毁 Game Object
    if self._go ~= nil then
        Object.Destroy(self._go)
    end

    self._go = nil
    self._tf = nil
    self._isLoaded = false

    -- 清空事件队列
    self.event:clear()
end

0x04 SceneManager

SceneManager 是一个很重要组件,而且因为责任的分工已经相当合理,所以这部分的代码并不多,也不难理解。

当 ViewComponent 的生命周期可控后,给出两个 ViewComponents 要实现他们的切换就变得简单了。然而切换的过程有很多种形式,可以根据自己的需要进行扩展,一般而言有以下两种:

  • 在当前场景加载下一场景,穿叉过场动画,然后删除前一场景;
  • 播放当前场景的退场动画,加载下一场景(loading),播放下一场景进场动画。

除了切换场景外,由于场景中的结点是可以嵌套的,所以SceneManager 还要处理在一个 ViewComponent 上增加/删除另一个 ViewComponent 的操作。实际上就是半个切换场景的过程。


SceneManager = {}

function SceneManager.transCross(from, to, onFinishCallback)

    -- TO load()
    -- TO loading
    -- TO loaded
    -- FROM exit()
    -- FROM didExit
    -- TO enter()
    -- TO didEnter
    -- callback()

    local function didEnter(e)
        onFinishCallback(to)
    end

    local function didExit(e)
        if to ~= nil then
            to.event:connect(BaseViewComponent.DID_ENTER, didEnter)
            to:enter()
        else
            didEnter()
        end
    end

    local function onLoaded(e)
        if from ~= nil then
            from.event:connect(BaseViewComponent.DID_EXIT, didExit)
            from:exit()
        else
            didExit()
        end
    end

    if to == nil or to:isLoaded() then
        onLoaded()
    else
        to.event:connect(BaseViewComponent.LOADED, onLoaded)
        to:load()
    end
end

function SceneManager.transOneByOne(from, to, onFinishCallback)

    -- FROM exit()
    -- FROM didExit
    -- TO load()
    -- TO loading
    -- TO loaded
    -- TO enter()
    -- TO didEnter
    -- callback()

    local function didEnter(e)
        onFinishCallback(to)
    end

    local function onLoaded(e)
        if to ~= nil then
            to.event:connect(BaseViewComponent.DID_ENTER, didEnter)
            to:enter()
        else
            didEnter()
        end
    end

    local function didExit(e)
        if to == nil or to:isLoaded() then
            onLoaded()
        else
            to.event:connect(BaseViewComponent.LOADED, onLoaded)
        end
    end

    if from ~= nil then
        from.event:connect(BaseViewComponent.DID_EXIT, didExit)
        from:exit()
    else
        didExit()
    end
end

function SceneManager.overlay(parent, child, onFinish)

    -- CHILD load()
    -- CHILD loading
    -- CHILD loaded
    -- CHILD attach()
    -- CHILD enter()
    -- CHILD didEnter
    -- callback()

    local function didEnter(e)
        onFinish(child)
    end

    local function onLoaded(e)
        if child ~= nil then
            child:attach(parent)
            child.event:connect(BaseViewComponent.DID_ENTER, didEnter)
            child:enter()
        else
            didEnter()
        end
    end

    if child == nil or child:isLoaded() then
        onLoaded()
    else
        child.event:connect(BaseViewComponent.LOADED, onLoaded)
        child:load()
    end
end

function SceneManager.remove(viewComponent, onFinishCallback)

    -- CHILD exit()
    -- CHILD didExit
    -- callback()

    if viewComponent ~= nil then
        if onFinishCallback then
            viewComponent.event:connect(BaseViewComponent.DID_EXIT, onFinishCallback)
        end

        viewComponent:exit()
    end
end

0x05 Base Mediator

整篇文章似乎都没有其它 Mediator 什么事。但是我们发现 GameMediator 只实现了场景级的 Mediator 的切换,如果想在指定的 Mediator 上嵌套子 Mediator,还需要做一些工作:

BaseMediator = class('BaseMediator', Mediator)

function BaseMediator:ctor(viewComponent)
    BaseMediator.super.ctor(self, nil, viewComponent)
end

-- 注册 Mediator
function BaseMediator:onRegister()
    -- 事件队列
    self.event = {}

    self:bind(BaseViewComponent.ON_BACK, function(e)
        self:sendNotification(GAME.GO_BACK)
    end)

    self:bind(BaseViewComponent.ON_CLOSE, function(e)
        local contextProxy = getProxy(ContextProxy)
        local currentContext = contextProxy:getCurrentContext()
        local parentContext = currentContext:getContextByMediator(self.class)
        self:sendNotification(GAME.REMOVE_LAYERS, {
            context = parentContext
        })
    end)

    -- 注册时要执行的
    self:didRegister()
end

-- 由子类实现注册时要执行的
function BaseMediator:didRegister() end

-- 设置上下文参数,通过 context.data 获得的参数
function BaseMediator:setContextData(data)
    self.contextData = data
end

-- 订阅来自 UI 的消息
-- @param event 事件名
-- @param callback 回调函数
function BaseMediator:bind(event, callback)
    -- 绑定 UI 事件回调
    self.viewComponent.event:connect(event, callback)

    -- 将事件记录到队列,注销 mediator 时称除
    table.insert(self.event, {
        event = event,
        callback = callback
    })
end

function BaseMediator:onRemove()
    -- 注销时要执行的
    self:willRemove()

    -- 移除关联的 UI 事件
    for _, v in ipairs(self.event) do
        self.viewComponent.event:disconnect(v.event, v.callback)
    end
end

-- 由子类实现注销时要执行的
function BaseMediator:willRemove() end

function BaseMediator:addSubLayers(context)
    local contextProxy = getProxy(ContextProxy)
    local currentContext = contextProxy:getCurrentContext()
    local parentContext = currentContext:getContextByMediator(self.class)

    self:sendNotification(GAME.LOAD_LAYERS, {
        parentContext = parentContext,
        context = context
    })
end

在主界需要弹出邮件子层模块的时候可以这样:

MainMediator = class("MainMediator", BaseMediator)

MainMediator.OPEN_MAIL = "MainUIMediator.OPEN_MAIL"

function MainMediator:register()
    -- ...
    self:bind(MainMediator.OPEN_MAIL, function(e)
        local childContext = Context.new({
            viewComponentClass = MailboxLayer,
            mediatorClass = MailboxMediator            
        })
        
        self:addSubLayers(childContext)
    end)
    -- ...
end

0x06 Summary

将原来混杂在 NestMediator 中的逻辑合理的分配到各个不同的组件。并对 ViewComponent 的生命周期进行详细的描述后,我发现这整个框架除了 ViewComponent 本身是依赖引擎的结点和资源管理以外,其它所有组件都是平台无关的。可以移植到任意平台中使用。至于如何回答 Part III 留下的问题,相信各位也有所领悟。希望这篇文章对你们有所启发 :)

0x07 Sequence Diagram

下图以登录场景切换成主场景为例,展示了交叉场景切换的时序图:

SceneMgr.png

图片使用 plantuml 绘制,另附 sublime text 插件:diagram

使用 PureMVC 构建游戏项目 Part III

期待已久的 Part III 来了,不过这次的标题并没有 Cocos2d-js ——因为这两个月我换了工作,重心已经从 Cocos2d-js 转移到 Unity3d + ulua 了。

不过我并没有放弃 PureMVC,并且在新的项目实践中验证了这个框架强大的平台无关性。正如 PureMVC 的理念那样,这是一个纯粹的基于设计模式的框架,内部使用观察者模式构建的消息机制,并不依赖于平台的消息机制。

之所以这么晚才写 Part III 是因为我希望在新项目中多总结提炼一些值得思考的问题,现在是时候跟大家分享了。虽然本文使用 Lua 语言的举例子,但是框架思路本身是通用的。

0x00 PureMVC.lua

虽然 PureMVC 官方并没有提供 Lua 版本,但是在 github 上可以找到两个国人移植的版本:

  • https://github.com/Ravior/puremvc-lua-framework
  • https://github.com/themoonbear/puremvc-lua

我使用的是后者,并在此基础上做了一些修订:

  • https://github.com/themoonbear/puremvc-lua/pull/2
  • https://github.com/themoonbear/puremvc-lua/pull/3

此外我自己移植了 statemachine 但是在实践中发现使用 statemachine 管理界面切换并无多大必要,所以本文就不深入讨论状态机了。

0x01 WHY MVC

为什么要在游戏中使用 MVC ?回顾 Part IPart II,我们试图使用 PureMVC 搭建的并不是游戏的核心部分(例如卡牌游戏的战斗场景)——而是游戏的外围系统(登录、选服、任务、邮件、编队、装备、活动等)。

MVC 能够提供一个良好的框架来搭建除实时交互部分之外的更接近应用层面的非实时交互部分。至于游戏的核心部分,我本人认为使用 MVC 模式去实现一个实时系统并不是一个合理的方式。幸好这部分的内容正好可以剥离在 MVC 之外,采用沙盒的形式向 MVC 传递少量关键操作信息即可(战斗暂停,返回,结束等)。

0x02 HOW MVC

重新回顾一下 puremvc,同时对 Part I 中的片面理解做一些纠正。

Facade

Facade 是 puremvc core 的门面,实际上它只是维护 model/view/controller 这三个容器,对 proxy/mediator/command 做增(register)删(remove)查(retrieve/has)操作。同时提供一个 sendNotification 方法在整个 core 中广播消息。

例如启动游戏这一步,就是创建一个 puremvc core 然后在 controller 中绑定一个 StartupCommand 命令,最后广播 STARTUP 消息,触发 StartupCommand 的执行:

game = pm.Facade.getInstance('game')
game:registerCommand(GAME.STARTUP, StartupCommand)
game:sendNotification(GAME.STARTUP)

在 puremvc 外,总是可以使用 game:sendNotification(msg, args) 向 core 广播消息。在 puremvc 内 proxy/mediator/command 都是 Notifer 的子类,所以他们的子类都可以使用 self:sendNotification(msg, args) 向 core 广播消息。

Model

Model 是 Proxy 的容器,而 Proxy 是存放游戏数据的地方。
可以按模块将 Model 划分如下:

  • PlayerProxy(玩家数据)
  • MailProxy(邮件数据)
  • TaskProxy(任务数据)
  • TeamProxy(角色数据)
  • BagProxy(背包数据)
  • StageProxy(关卡数据)
  • ...

此外,我们应该对游戏使用的基本数据对象进行封装,创建相应的值对象(Value Object,简称 VO),例如:Player/Mail/Task/Hero/Item/Stage/...

这些值对象存放着相对应的服务端数据,并且提供一些简单的接口来处理对应逻辑,如:

Player = class("Player", BaseVO)

function Player:ctor(data)
    self.id = data.id
    self.level = data.level
    self.exp = data.exp
    self.coin = data.coin
    self.gem = data.gem
end

function Player:hasEnoughCoin(coin)
    return self.coin >= coin
end

function Player:canLevelUp(exp)
    -- TODO: test if player can level with adding exp
    return false
end

BaseVO 是所有 Value Object 的基类,实现了一些例如 clone() 之类的方法。

除了 PlayerProxy 只持有一个 Player 值对象外,其它 Proxy 往往是以集合的形式持有一批同类的值对象。所以他们的行为很像容器,可以是数组,栈或者哈希表。并且提供了增删改查值对象的功能。当容器内的值对象被增删改时,会向系统广播相应的信息,并在这些信息中携带相应值对象的副本(clone):

BagProxy = class("BagProxy", Proxy)

function BagProxy:ctor()
    -- initial data
    self.super.ctor(self, {}, self.__cname)
end

BagProxy.ITEM_ADDED = "BagProxy:ITEM_ADDED"
BagProxy.ITEM_UPDATED = "BagProxy:ITEM_UPDATED"
BagProxy.ITEM_REMOVED = "BagProxy:ITEM_REMOVED"

function BagProxy:addItem(item)
    assert(self.data[item.id] == nil, "item already exist, use updateItem() instead.")

    self.data[item.id] = item:clone()
    self:sendNotification(BagProxy.ITEM_ADDED, item:clone())
end

function BagProxy:updateItem(item)
    assert(self.data[item.id], "item should exist.")

    self.data[item.id] = item:clone()
    self:sendNotification(BagProxy.ITEM_UPDATED, item:clone())
end

function BagProxy:removeItem(item)
    local item = self.data[item.id]
    assert(item, "item should exist")

    self.data[item.id] = nil
    self:sendNotification(BagProxy.ITEM_REMOVED, item)
end

function BagProxy:getItemById(itemId)
    if self.data[itemId] then
        return self.data[itemId]:clone()
    end
    return nil
end

为什么要大量使用 clone 呢?其实这是一种防御式编程。由于这些值对象随时可以被创建或引用,很可能在写代码的时候不经意间修改了值对象,但是忘了更新到 Proxy 中;或者在添加到 Proxy 后又去修改同一个值对象,系统的其它部分察觉不到修改,神不知鬼不觉就埋下了 bug 。

使用 clone 的好处是你可以随意地修改并使用值对象,直接看到修改后的效果,而在验证逻辑正确后,再使用 Proxy 的 update 方法通知整个系统某个值对象的变化。例如在界面中操作经验药水与人物,经验药水和人物的值对象都是副本,可以随意修改 count 或 exp 字段,界面根据这些值对象当前的数据进行更新即可看到效果。如果玩家取消这些操作,只需要重新去 Proxy 里取出原始的值对象,重新更新界面即可还原这些修改。可见实现值对象的深拷贝是非常安全且有意义的。

相关阅读:Lua 深拷贝的实现

View

View 是 Mediator 的容器。Mediator 负责维护视图组件(View Component)侦听来自视图的事件(例如触发某个按钮),并传发到系统中,以执行相应的 Command 。另外,Mediator 还关注来自系统的消息,并调用视图提供的接口更新相应的界面。

在开发的过程中按功能编写相应的 Mediator 和 ViewComponent:

  • MainMediator / MainScene (主界面)
  • MailMediator / MailLayer (邮件界面)
  • TaskMediator / TaskScene (任务界面)
  • StageMediator / StageScene (关卡界面)
  • ...

场景级界面(Scene)通常互不共存,而层级界面(Layer)可以层叠于场景级界面之上。但每个 Meidator 只能有一个实例在系统中,切换场景或关闭后被移除。

将 Mediator 与界面分离有非常多的好处。Mediator 只关心业务逻辑,而具体的界面长什么样,它并不需要操心。而视图可以专心处理各种复杂的界面效果,最终只把与业务逻辑有关的事件传达给 Mediator,并提供一些接口给 Mediator 用来处理 Model 层数据变化时要接收的值对象。前期开发的时候,如果没有具体的界面,只需要完成接口操作即可,实际界面可以在后期随时变更。下面是登录模块的示例:

LoginMediator = class("LoginMediator", BaseMediator)

LoginMediator.ON_LOGIN = "LoginMediator:ON_LOGIN"
LoginMediator.ON_SERVER = "LoginMediator:ON_SERVER"

function LoginMediator:onRegister()
    self.viewComponent.event:connect(LoginMediator.ON_LOGIN, function(e, user)
        self:sendNotification(GAME.LOGIN, user)
    end)

    self.viewComponent.event:connect(LoginMediator.ON_SERVER, function(e, user)
        self:sendNotification(GAME.LOGIN, user)
    end)
end

function LoginMediator:onRemove()
    self.viewComponent.event:clear(LoginMediator.ON_LOGIN)
    self.viewComponent.event:clear(LoginMediator.ON_SERVER)
end

function LoginMediator:listNotificationInterests()
    return {
        GAME.USER_LOGIN_SUCCESS,
        GAME.USER_LOGIN_FAILED
    }
end

function LoginMediator:handleNotification(note)
    local name = note:getName()
    local body = note:getBody()

    if name == GAME.USER_LOGIN_SUCCESS then
        local serverProxy = getProxy(ServerProxy)
        local servers = serverProxy:getServers()
        self.viewComponent:updateServerList(servers)

    elseif name == GAME.USER_LOGIN_FAILED then
        self.viewComponent:displayLoginError()

    end
end

LoginMediator 关心界面点击登录按钮时提交上来的用户值对象,然后使用将这个对象交给相应的 Command 完成登录;接着影响登录成功的消息,加载并显示服列表,如果登录失败,弹出相应的失败信息。

此外 LoginMediator 还关心玩家从服务器列表选择了哪个服务器,然后将这个服务器值对象转发给系统相应的 Command 完成进入服务器操作。

实际上 Mediator 承担着一份系统与视图之间的接口协议的职责。从上面两个需求,我们很快知道登录界面要做哪些事情:

  • 当用户点击注册按钮时向 LoginMediator 发送 LoginMediator.ON_LOGIN 事件,并传递从界面上收集来的用户信息;
  • 提供一个 updateServerList 方法,当有新的服列表可用时,传入服列表数据,并更新界面;
  • 当用户选择一个服务器的时候,向 LoginMediator 发送 LoginMediator.ON_SERVER 事件,并传递相应的服务器信息;

于是一个临时的登录界面就可以出炉了:

local LoginScene = class("LoginScene", BaseUI)

function LoginScene:onEnter()

    -- test login
    self.event:emit(LoginMediator.ON_LOGIN, User.New({
        username = "test"
        password = "test"
    }))
end

function LoginScene:updateServerList(servers)

    -- test on server
    self.event:emit(LoginMediator.ON_SERVER, servers[1])
end

function LoginScene:displayLoginError()
    print("can not login to server")
end

注意到 Mediator 和视图之间的通讯并不是通过 sendNotification,而是私有的一套机制。在这里我使用的是 LuaNotify。(使用 javascript 的同学可以试试 emitter ,毕竟 cocos2d-js 的那个 CCEventManager 太难用了。)

Controller

Controller 是 Command 的容器。注册 Command 的时候,只把 Command 类与关联的消息绑定起来,等到消息被触发时,才实例化对应的 Command 并执行 execute 方法。

在 Part I 中由于认识不足,忽视了 Command 的潜在能力,将大量本该放在 Command 中的业务逻辑塞到了 Proxy 和 Mediator 里面。使得代码条理不清晰,Proxy 与 Mediator 偶合度也提高了很多。

由于是手机网游,大多数 Command 会触发网络消息,并注册异步的回调函数,Command在执行完后并没有被 GC,而是等回调函数执行完后才被垃圾回收。以下是装备打造的示例:

local BuildCommand = class('BuildCommand', SimpleCommand)

function BuildCommand:execute(note)
    local data = note:getBody()

    local consumables = {
        gold = data.gold,
        oil = data.oil,
        silver = data.silver,
    }

    -- check resources
    for k, v in pairs(consumables) do
        if not (50 <= v and v <= 999) then
            alert(k .. " is not in range [50, 999]: " .. v)
            return
        end
    end

    local playerProxy = self.facade:retrieveProxy(PlayerProxy)
    local player = playerProxy:getData()

    if not player:isEnough(consumables) then
        alert("resources are not enough")
        return
    end

    NetManager:send(PROTOCOL.BUILD, consumables, function(data)
        if data.result == 0 then
            player:consume(consumables)
            playerProxy:updatePlayer(player)

            local bagProxy = self.facade:retrieveProxy(BagProxy)
            local item  = Equipment.new(data.item)
            bagProxy:addItem(item)

            self:sendNotification(GAME.BUILT, item)
        else
            print("can not build equipment: " + data.result)
        end
    end)
end

装备打造命令,传入使用的资源数量,并按策划案做一些本地检查,如果资源不足,直接就终止操作并进行提示,而无须发送到服务端再检查。如果正常,则调用网络接口发送协议内容,等待回调。

在回调中处理最终的数据变化,并使用 Proxy 更新数据,Proxy 会把这些变化通知到系统中。此外,对于重要消息,可以在最后额外作一些通知。以便让相关的界面作一些视觉上的效果。

0x03 To be continue

有人说没有必要把界面拆成 Mediator 和 View Component,直接把所有逻辑都放在 Mediator 就好了。由于篇幅过长,这里暂时不予反驳,待我开个 Part IV 详细介绍场景管理时解释。到时候会详细剖析 Part II 提到的场景栈重构。

2015/08/25 updated:

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

手机网游的用户中心设计

在开发手机网游的过程中,为玩家建立一个用户中心,比起直接在游戏服务器上直接处理玩家登录,将会带来很多益处。用户中心负责管理独立于游戏数据的玩家帐号信息。并且一个玩家可以在多个服务器上甚至同一品牌下的多个游戏有不同的游戏帐号,当游戏服务器不断增加时,用户可以集中管理。同时这也符合单一职责原则。

此外,由于是手机网游,通常玩家不会愿意为注册帐号花太多时间。很多游戏引入了第三方平台 SDK,可以使用第三方帐号登录游戏;甚至支持直接产生临时帐号进行游客登录,等到玩家消费或认可游戏后再进行帐号绑定。我们要如何设计一个系统来支持这些功能呢?

0x01 USERNAME : PASSWORK

既然是用户中心,首先来看看最一般情况的登录模型:

username: _________
password: _________

[REGISTER]  [LOGIN]

玩家打开游戏后,显示登录框,可以注册或直接登录游戏;
这里需要用户中心提供一些 API 来完成登录过程。

所以 API 应该是什么样子的呢?输入是什么,输出又是什么?可以肯定的是我们要把用户名密码提交到用户中心,用户中心将返回登录(或注册)的结果

另外在 API 中我们可以提供一些冗余字段为之后的扩展做一些准备,例如登录类型(普通、SDK、游客),平台参数(来自哪个SDK),目标游戏(登录哪个游戏)等。

而返回结果是什么呢?登录成功,或是失败以及失败的原因——但这并不是一个让玩家记住密码的游戏——我们需要将登录成果反馈给游戏服务器,从而进入游戏世界。如何实现这一过程?

当玩家输入错误的用户名及密码时,我们只需要进行友好的提示即可。如果玩家登录成功,客户端会得到一个令牌(TOKEN),在接下来的过程中,客户端使用这个令牌向游戏大厅请求服务器列表,最终使用这个令牌登陆游戏服务器。

TOKEN

令牌可以看作是一种协议,可以用对称或非对称加密的形式将登录成功的信息封装起来,例如令牌的有效期玩家ID 等。这些信息对玩家是透明的,客户端只是得到一个加密串。然而用户中心与游戏服务器之间共享着能够正确解密这个加密串的钥匙,所以游戏服务器最终能知道玩家的ID是否来自用户中心合法的授权。对于不同的游戏平台,可以使用不同的密钥,避免不合法的访问。

LOGIN API

user_center/login

参数

  • username = hello
  • password = *****
  • platform = iOS/Android

成功

  • state = ok
  • msg = *T*O*K*E*N*

失败

  • state = error
  • msg = user does not exist or password is wrong

REGISTER API

user_center/register

参数

  • username = hello
  • password = *****

成功

  • state = ok
  • msg = *T*O*K*E*N*

失败

  • state = error
  • msg = username is taken

0x02 SDK : UID : TOKEN

在手机游戏中,使用第三方平台 SDK 登录也是非常常见的:

[LOGIN with GOOGLE]
[LOGIN with WECHAT]
[LOGIN with WEIBO ]

假若用户已经拥有第三方平台的帐号,而我们在游戏中接入了相应的平台。那么我们就可以使用平台提供的 SDK 进行用户登录。登录的过程由 SDK 提供,可能的形式各不相同:

  • 在弹出的窗口中完成帐号登录
  • 跳转至特定的应用中完成授权

其本质上都是基于 OAuth 协议完成的用户授权。客户端能够得到 SDK 返回的 SDK-UID (用户标识)以及 SDK-TOKEN ,而后我们将这些信息发送到用户中心进行登录。

用户中心根据 SDK 平台标识、SDK-UID 以及 SDK-TOKEN 向 SDK 的 OAuth 服务器发起验证,确认 SDK-UID 的合法性后,返回登录结果。

SDK LOGIN API

user_center/sdk_login

参数

  • uid = 10000
  • sdk-token = *S*D*K*T*O*K*E*N*
  • platform = google/wechat/weibo/4399/TongBu/91/...

成功

  • state = ok
  • msg = *T*O*K*E*N*

失败

  • state = error
  • msg = sdk token is not correct

0x03 GUEST

游客登录提供了更加便捷的登录方式,让那些并不想注册的玩家也有机会体验游戏:

[LOGIN AS GUEST]

玩家点击游戏客录,若本地存有上一次登录的游客令牌,则使用该游客令牌进行登录。若没有,则直接登录,并保存获得的游客令牌,供下次使用。

这种方式的缺点是不安全,玩家可以伪造其它游客的令牌从而进入游戏(可采用更加随机的方式才生令牌,避免预测);游戏删除或存档丢失后无法再登录原来的帐号;并且无法在其它设备上使用该帐号。

GUEST LOGIN API

user_center/guest

参数

  • guest-id = *G*U*E*S*T*I*D*

成功

  • state = ok
  • msg = *T*O*K*E*N*

新游客

  • state = new
  • msg = *T*O*K*E*N*
  • guest-id = *G*U*E*S*T*I*D*

0x04 BIND

帐号绑定是一个向第三方 SDK 平台的玩家,或者游客提供的功能:

SDK-ID: 001 / GUEST

username: _________
password: _________

[REGISTER and BIND]

绑定后,游客将变成注册用户,可以使用用户名和密码登录,这意味着玩家可以在不同的设备上使用该帐号。

当第三方 SDK 平台出现网络故障的时候,会导致玩家不能进入游戏,这是很尴尬的局面,但是如果玩家绑定了帐号,则可以尝试使用用户名和密码进入同一个游戏帐号。

Bind API

user_center/bind

参数

  • token = *T*O*K*E*N
  • username = hello
  • password = *****
  • platform = iOS/Android

成功

  • state = ok

失败

  • state = error
  • msg = user already bind with another account

0x05 DATABASE

定义了这些功能和接口,我们可以对数据库进行设计。看看需要哪些字段

            TYPE        KEY    IS_NULL
uid         AUTO_INC    YES    NO
username    VARCHAR     -      YES
password    VARCHAR     -      YES
sdk         VARCHAR     -      YES
sdk_uid     VARCHAR     -      YES
sdk_token   VARCHAR     -      YES
guest_id    VARCHAR     -      YES

虽然之前的 API 都没有出现 uid 但这个字段是必须的,游戏服务器通过它与用户中心里的帐号关联起来,uid 会被加密到令牌中,通过客户端发送给游戏服务器。

若 guest_id 不为空,则该用户是游客,可以匹配客户端发送的 guest-id 进行登录,否则需要通过其它两种方式进行登录。而 username 和 sdk 可以同时存在,也可以独立存在。

0x06 MORE

用户中心是否需要做权限管理:例如增加权限位,设置 GM 或测试人员,以及封禁帐号?如果这个用户中心只为一个游戏服务,那么完全可以加上这些功能,甚至提供一些游戏大厅的功能:根据平台和权限分配服务器列表等等;如果这个用户中心将服务于多款游戏,那么最好将这些功能分离到另外的游戏大厅或网关服务器去实现。至此用户中心的职责就明了了。

在 mac osx 下进行 ulua 远程调试

更新于 2015/10/13:
ulua 最新版已经直接支持 mac 调试:ulua怎么与ZeroBrane Studio联合调试?

ulua 是一款 unity3d 4.6/5.1 插件,能够使用 lua 语言进行 unity3d 游戏开发。但使用嵌入式脚本语言进行游戏开发带来便利的同时也对调试程序也带来了各种考验。

有幸找到 MobDebug ,一款用于远程调试 Lua 的神器。集服务端与客户端于一身,集成于多种 Lua IDE 中,例如 ZeroBrane Studio 。于是我打算尝试使用 ZeroBrane Studio 对运行中的 ulua 进行远程调试。

0x00 Getting Started

LuarRocks 是一个用于 Lua 的包管理器,在 Mac OSX 上的安装方法见此文档

使用它安装 MobDebug 非常快捷:

$ luarocks install mobdebug

然后在游戏的 main.lua 脚本的第一行加上:

require("mobdebug").start()

启动 ZeroBrane Studio 选择游戏项目所在目录;
执行 Project > Start Debugger Server ;

等待游戏客户端启动后,即可在 ZeroBrane Studio 进行各种断点调试。

关于 ZeroBrane Studio 远程调试更多的内容见此文档

0x01 Problems

在实践中,可能没有像 part 0x00 那么顺利,如果遇到以下问题,请逐一解决:

P1. 找不到 socket 库

LuaScriptException: [string "mobdebug.lua"]:101: module 'socket' not found:
    no field package.preload['socket']
    no file './socket.lua'
    no file '/usr/local/share/lua/5.1/socket.lua'
    no file '/usr/local/share/lua/5.1/socket/init.lua'
    no file '/usr/local/lib/lua/5.1/socket.lua'
    no file '/usr/local/lib/lua/5.1/socket/init.lua'
    no file './socket.so'
    no file '/usr/local/lib/lua/5.1/socket.so'
    no file '/usr/local/lib/lua/5.1/loadall.so'

使用 LuaRocks 安装 luasocket :

$ luarocks install luasocket

P2. luasocket 与 ulua 不兼容

LuaScriptException: error loading module 'socket.core' from file '/usr/local/lib/lua/5.1/socket/core.so':
    dlopen(/usr/local/lib/lua/5.1/socket/core.so, 2): no suitable image found.  Did find:
    /usr/local/lib/lua/5.1/socket/core.so: mach-o, but wrong architecture

由于处在 32bit 与 64bit 的过渡时期,ulua 对不同平台的兼容方案如下:

Windows / Android 使用 luajit 2.0.1 i386 arm7
Mac OSX / iOS 使用 lua 5.1 i386 x86_64 arm7 arm64

luajit 2.0 不支持 64bit 的 bytecode
luajit 2.1 还在开发中
luac 在 64bit 系统下编译的 bytecode 不能在 32bit 系统下使用,反过来也是
ulua 使用修改过的 i386 / x86_64 的 lua 实际上只能解析 32bit 的 bytecode

而在 64bit Mac OSX 下使用 luarocks 编译安装的 luasocket 默认是 x86_64 架构的,所以出现不兼容情况。

我们需要修改 luarocks 的编译和链接参数,增加 i386 构架的支持:

$ vi /usr/local/share/lua/5.1/luarocks/cfg.lua

找到 if detected.unix then 后面的 defaults.variables.CFLAGS 在参数中追加 -arch i386 -arch x86_64

if detected.unix then
    ...
    defaults.variables.CFLAGS = "-O2 -arch i386 -arch x86_64"
    ...

同上,找到 if detected.macosx then 后面的 defaults.variables.LD 在参数中追加 -arch i386 -arch x86_64

if detected.macosx then
    ...
    defaults.variables.LD = "export MACOSX_DEPLOYMENT_TARGET=10."..version.."; gcc -arch i386 -arch x86_64"
    ...

保存 cfg.lua 后重新安装 luasocket 即可:

$ luarocks install luasocket

本文使用的是 LuaRocks 2.2.2 ,其它版本的配置文件略有不同;
如果您使用的是 LuaRocks 2.0.x 请参考此文