Golang Channel用法简编

No Comments

在进入正式内容前,我这里先顺便转发一则消息,那就是Golang 1.3.2已经正式发布了。国内的golangtc已经镜像了golang.org的安装包下载页面,国内go程序员与爱好者们可以到"Golang中 国",即golangtc.com去下载go 1.3.2版本。

Go这门语言也许你还不甚了解,甚至是完全不知道,这也有情可原,毕竟Go在TIOBE编程语言排行榜上位列30开外。但近期使用Golang 实现的一杀手级应用 Docker你却不该不知道。docker目前火得是一塌糊涂啊。你去国内外各大技术站点用眼轻瞥一下,如 果没有涉及到“docker”字样新闻的站点建 议你以后就不要再去访问了^_^。Docker是啥、怎么用以及基础实践可以参加国内一位仁兄的经验之作:《 Docker – 从入门到实践》。

据我了解,目前国内试水Go语言开发后台系统的大公司与初创公司日益增多,比如七牛、京东、小米,盛大,金山,东软,搜狗等,在这里我们可以看到一些公司的Go语言应用列表,并且目前这个列表似乎依旧在丰富中。国内Go语言的推广与布道也再稳步推进中,不过目前来看多以Go入 门与基础为主题,Go idioms、tips或Best Practice的Share并不多见,想必国内的先行者、布道师们还在韬光养晦,积攒经验,等到时机来临再厚积薄发。另外国内似乎还没有一个针对Go的 布道平台,比如Golang技术大会之类的的平台。

在国外,虽然Go也刚刚起步,但在Golang share的广度和深度方面显然更进一步。Go的国际会议目前还不多,除了Golang老东家Google在自己的各种大会上留给Golang展示自己的 机会外,由 Gopher Academy 发起的GopherCon 会议也于今年第一次举行,并放出诸多高质量资料,在这里可以下载。欧洲的Go语言大会.dotgo也即将开幕,估计后续这两个大会将撑起Golang技术分享 的旗帜。

言归正传,这里要写的东西并非原创,自己的Go仅仅算是入门级别,工程经验、Best Practice等还谈不上有多少,因此这里主要是针对GopherCon2014上的“舶来品”的学习心得。来自CloudFlare的工程师John Graham-Cumming谈了关于 Channel的实践经验,这里针对其分享的内容,记录一些学习体会和理解,并结合一些外延知识,也可以算是一种学习笔记吧,仅供参考。

一、Golang并发基础理论

Golang在并发设计方面参考了C.A.R Hoare的CSP,即Communicating Sequential Processes并发模型理论。但就像John Graham-Cumming所说的那样,多数Golang程序员或爱好者仅仅停留在“知道”这一层次,理解CSP理论的并不多,毕竟多数程序员是搞工程 的。不过要想系统学习CSP的人可以从这里下载到CSP论文的最新版本。

维基百科中概要罗列了CSP模型与另外一种并发模型Actor模型的区别:

Actor模型广义上讲与CSP模型很相似。但两种模型就提供的原语而言,又有一些根本上的不同之处:
    – CSP模型处理过程是匿名的,而Actor模型中的Actor则具有身份标识。
    – CSP模型的消息传递在收发消息进程间包含了一个交会点,即发送方只能在接收方准备好接收消息时才能发送消息。相反,actor模型中的消息传递是异步 的,即消息的发送和接收无需在同一时间进行,发送方可以在接收方准备好接收消息前将消息发送出去。这两种方案可以认为是彼此对偶的。在某种意义下,基于交 会点的系统可以通过构造带缓冲的通信的方式来模拟异步消息系统。而异步系统可以通过构造带消息/应答协议的方式来同步发送方和接收方来模拟交会点似的通信 方式。
    – CSP使用显式的Channel用于消息传递,而Actor模型则将消息发送给命名的目的Actor。这两种方法可以被认为是对偶的。某种意义下,进程可 以从一个实际上拥有身份标识的channel接收消息,而通过将actors构造成类Channel的行为模式也可以打破actors之间的名字耦合。

二、Go Channel基本操作语法

Go Channel的基本操作语法如下:

c := make(chan bool) //创建一个无缓冲的bool型Channel

c <- x        //向一个Channel发送一个值
<- c          //从一个Channel中接收一个值
x = <- c      //从Channel c接收一个值并将其存储到x中
x, ok = <- c  //从Channel接收一个值,如果channel关闭了或没有数据,那么ok将被置为false

不带缓冲的Channel兼具通信和同步两种特性,颇受青睐。

三、Channel用作信号(Signal)的场景

1、等待一个事件(Event)

等待一个事件,有时候通过close一个Channel就足够了。例如:

//testwaitevent1.go
package main

import "fmt"

func main() {
        fmt.Println("Begin doing something!")
        c := make(chan bool)
        go func() {
                fmt.Println("Doing something…")
                close(c)
        }()
        <-c
        fmt.Println("Done!")
}

这里main goroutine通过"<-c"来等待sub goroutine中的“完成事件”,sub goroutine通过close channel促发这一事件。当然也可以通过向Channel写入一个bool值的方式来作为事件通知。main goroutine在channel c上没有任何数据可读的情况下会阻塞等待。

关于输出结果:

根据《Go memory model》中关于close channel与recv from channel的order的定义:The closing of a channel happens before a receive that returns a zero value because the channel is closed.

我们可以很容易判断出上面程序的输出结果:

Begin doing something!
Doing something…
Done!

如果将close(c)换成c<-true,则根据《Go memory model》中的定义:A receive from an unbuffered channel happens before the send on that channel completes.
"<-c"要先于"c<-true"完成,但也不影响日志的输出顺序,输出结果仍为上面三行。

2、协同多个Goroutines

同上,close channel还可以用于协同多个Goroutines,比如下面这个例子,我们创建了100个Worker Goroutine,这些Goroutine在被创建出来后都阻塞在"<-start"上,直到我们在main goroutine中给出开工的信号:"close(start)",这些goroutines才开始真正的并发运行起来。

//testwaitevent2.go
package main

import "fmt"

func worker(start chan bool, index int) {
        <-start
        fmt.Println("This is Worker:", index)
}

func main() {
        start := make(chan bool)
        for i := 1; i <= 100; i++ {
                go worker(start, i)
        }
        close(start)
        select {} //deadlock we expected
}

3、Select

【select的基本操作】
select是Go语言特有的操作,使用select我们可以同时在多个channel上进行发送/接收操作。下面是select的基本操作。

select {
case x := <- somechan:
    // … 使用x进行一些操作

case y, ok := <- someOtherchan:
    // … 使用y进行一些操作,
    //
检查ok值判断someOtherchan是否已经关闭

case outputChan <- z:
    // … z值被成功发送到Channel上时

default:
    // … 上面case均无法通信时,执行此分支
}

【惯用法:for/select】

我们在使用select时很少只是对其进行一次evaluation,我们常常将其与for {}结合在一起使用,并选择适当时机从for{}中退出。

for {
        select {
        case x := <- somechan:
            // … 使用x进行一些操作

        case y, ok := <- someOtherchan:
            // … 使用y进行一些操作,
            // 检查ok值判断someOtherchan是否已经关闭

        case outputChan <- z:
            // … z值被成功发送到Channel上时

        default:
            // … 上面case均无法通信时,执行此分支
        }
}

【终结workers】

下面是一个常见的终结sub worker goroutines的方法,每个worker goroutine通过select监视一个die channel来及时获取main goroutine的退出通知。

//testterminateworker1.go
package main

import (
    "fmt"
    "time"
)

func worker(die chan bool, index int) {
    fmt.Println("Begin: This is Worker:", index)
    for {
        select {
        //case xx:
            //做事的分支
        case <-die:
            fmt.Println("Done: This is Worker:", index)
            return
        }
    }
}

func main() {
    die := make(chan bool)

    for i := 1; i <= 100; i++ {
        go worker(die, i)
    }

    time.Sleep(time.Second * 5)
    close(die)
    select {}
//deadlock we expected
}

【终结验证】

有时候终结一个worker后,main goroutine想确认worker routine是否真正退出了,可采用下面这种方法:

//testterminateworker2.go
package main

import (
    "fmt"
    //"time"
)

func worker(die chan bool) {
    fmt.Println("Begin: This is Worker")
    for {
        select {
        //case xx:
        //做事的分支
        case <-die:
            fmt.Println("Done: This is Worker")
            die <- true
            return
        }
    }
}

func main() {
    die := make(chan bool)

    go worker(die)

    die <- true
    <-die
    fmt.Println("Worker goroutine has been terminated")
}

【关闭的Channel永远不会阻塞】

下面演示在一个已经关闭了的channel上读写的结果:

//testoperateonclosedchannel.go
package main

import "fmt"

func main() {
        cb := make(chan bool)
        close(cb)
        x := <-cb
        fmt.Printf("%#v\n", x)

        x, ok := <-cb
        fmt.Printf("%#v %#v\n", x, ok)

        ci := make(chan int)
        close(ci)
        y := <-ci
        fmt.Printf("%#v\n", y)

        cb <- true
}

$go run testoperateonclosedchannel.go
false
false false
0
panic: runtime error: send on closed channel

可以看到在一个已经close的unbuffered channel上执行读操作,回返回channel对应类型的零值,比如bool型channel返回false,int型channel返回0。但向close的channel写则会触发panic。不过无论读写都不会导致阻塞。

【关闭带缓存的channel】

将unbuffered channel换成buffered channel会怎样?我们看下面例子:

//testclosedbufferedchannel.go
package main

import "fmt"

func main() {
        c := make(chan int, 3)
        c <- 15
        c <- 34
        c <- 65
        close(c)
        fmt.Printf("%d\n", <-c)
        fmt.Printf("%d\n", <-c)
        fmt.Printf("%d\n", <-c)
        fmt.Printf("%d\n", <-c)

        c <- 1
}

$go run testclosedbufferedchannel.go
15
34
65
0
panic: runtime error: send on closed channel

可以看出带缓冲的channel略有不同。尽管已经close了,但我们依旧可以从中读出关闭前写入的3个值。第四次读取时,则会返回该channel类型的零值。向这类channel写入操作也会触发panic。

【range】

Golang中的range常常和channel并肩作战,它被用来从channel中读取所有值。下面是一个简单的实例:

//testrange.go
package main

import "fmt"

func generator(strings chan string) {
        strings <- "Five hour's New York jet lag"
        strings <- "and Cayce Pollard wakes in Camden Town"
        strings <- "to the dire and ever-decreasing circles"
        strings <- "of disrupted circadian rhythm."
        close(strings)
}

func main() {
        strings := make(chan string)
        go generator(strings)
        for s := range strings {
                fmt.Printf("%s\n", s)
        }
        fmt.Printf("\n")
}

四、隐藏状态

下面通过一个例子来演示一下channel如何用来隐藏状态:

1、例子:唯一的ID服务

//testuniqueid.go
package main

import "fmt"

func newUniqueIDService() <-chan string {
        id := make(chan string)
        go func() {
                var counter int64 = 0
                for {
                        id <- fmt.Sprintf("%x", counter)
                        counter += 1
                }
        }()
        return id
}
func main() {
        id := newUniqueIDService()
        for i := 0; i < 10; i++ {
                fmt.Println(<-id)
        }
}

$ go run testuniqueid.go
0
1
2
3
4
5
6
7
8
9

newUniqueIDService通过一个channel与main goroutine关联,main goroutine无需知道uniqueid实现的细节以及当前状态,只需通过channel获得最新id即可。

五、默认情况

我想这里John Graham-Cumming主要是想告诉我们select的default分支的实践用法。

1、select  for non-blocking receive

idle:= make(chan []byte, 5) //用一个带缓冲的channel构造一个简单的队列

select {
case b = <-idle:
 //尝试从idle队列中读取
    …
default:  //队列空,分配一个新的buffer
        makes += 1
        b = make([]byte, size)
}

2、select for non-blocking send

idle:= make(chan []byte, 5) //用一个带缓冲的channel构造一个简单的队列

select {
case idle <- b: //尝试向队列中插入一个buffer
        //…
default: //队列满?

}

六、Nil Channels

1、nil channels阻塞

对一个没有初始化的channel进行读写操作都将发生阻塞,例子如下:

package main

func main() {
        var c chan int
        <-c
}

$go run testnilchannel.go
fatal error: all goroutines are asleep – deadlock!

package main

func main() {
        var c chan int
        c <- 1
}

$go run testnilchannel.go
fatal error: all goroutines are asleep – deadlock!

2、nil channel在select中很有用

看下面这个例子:

//testnilchannel_bad.go
package main

import "fmt"
import "time"

func main() {
        var c1, c2 chan int = make(chan int), make(chan int)
        go func() {
                time.Sleep(time.Second * 5)
                c1 <- 5
                close(c1)
        }()

        go func() {
                time.Sleep(time.Second * 7)
                c2 <- 7
                close(c2)
        }()

        for {
                select {
                case x := <-c1:
                        fmt.Println(x)
                case x := <-c2:
                        fmt.Println(x)
                }
        }
        fmt.Println("over")
}

我们原本期望程序交替输出5和7两个数字,但实际的输出结果却是:

5
0
0
0
… … 0死循环

再仔细分析代码,原来select每次按case顺序evaluate:
    – 前5s,select一直阻塞;
    – 第5s,c1返回一个5后被close了,“case x := <-c1”这个分支返回,select输出5,并重新select
    – 下一轮select又从“case x := <-c1”这个分支开始evaluate,由于c1被close,按照前面的知识,close的channel不会阻塞,我们会读出这个 channel对应类型的零值,这里就是0;select再次输出0;这时即便c2有值返回,程序也不会走到c2这个分支
    – 依次类推,程序无限循环的输出0

我们利用nil channel来改进这个程序,以实现我们的意图,代码如下:

//testnilchannel.go
package main

import "fmt"
import "time"

func main() {
        var c1, c2 chan int = make(chan int), make(chan int)
        go func() {
                time.Sleep(time.Second * 5)
                c1 <- 5
                close(c1)
        }()

        go func() {
                time.Sleep(time.Second * 7)
                c2 <- 7
                close(c2)
        }()

        for {
                select {
                case x, ok := <-c1:
                        if !ok {
                                c1 = nil
                        } else {
                                fmt.Println(x)
                        }
                case x, ok := <-c2:
                        if !ok {
                                c2 = nil
                        } else {
                                fmt.Println(x)
                        }
                }
                if c1 == nil && c2 == nil {
                        break
                }
        }
        fmt.Println("over")
}

$go run testnilchannel.go
5
7
over

可以看出:通过将已经关闭的channel置为nil,下次select将会阻塞在该channel上,使得select继续下面的分支evaluation。

七、Timers

1、超时机制Timeout

带超时机制的select是常规的tip,下面是示例代码,实现30s的超时select:

func worker(start chan bool) {
        timeout := time.After(30 * time.Second)
        for {
                select {
                     // … do some stuff
                case <- timeout:
                    return
                }
        }
}

2、心跳HeartBeart

与timeout实现类似,下面是一个简单的心跳select实现:

func worker(start chan bool) {
        heartbeat := time.Tick(30 * time.Second)
        for {
                select {
                     // … do some stuff
                case <- heartbeat:
                    //… do heartbeat stuff
                }
        }
}

Ubuntu Server 14.04安装docker

No Comments

近期在研究docker这一轻量级容器引擎,研究docker对日常开发测试工作以及产品部署运维工作能带来哪些便利。前些时候刚刚将工作环境从 Ubuntu搬到了Mac Air上,对Mac OS X的一切均不甚熟悉,给docker研究带来了不便,于是打算在VirtualBox中安装一Ubuntu Server作为docker之承载平台。这里记录一下安装配置过程,主要为了备忘,如果能给其他人带来帮助,我会甚感欣慰。

docker官方对ubuntu的支持是蛮好的。docker对Linux内核版本有要求,要>=3.8,Ubuntu Server目前最新版本14.04.1恰符合这一要求,其kernel version = 3.13.0-32。

一、VirtualBox安装Ubuntu Server 14.04.1

VirtualBox安装Ubuntu OS做过了不止一遍,即便是换成最新的14.04.1 Server版,差别也没有太多,无非是按照安装提示,逐步Next。这里给Ubuntu Server 14.04分配了1G Memory, 32G动态硬盘空间。

【配置源】

  默认情况下,/etc/apt/sources.list中只有一组源:cn.archive.ubuntu.com/ubuntu。这个国外源的下载速度显然无法满足我的要求,于是我把我常用的sohu源加入sources.list中,并且放在前面:

  deb http://mirrors.sohu.com/ubuntu/ trusty main restricted
  deb http://mirrors.sohu.com/ubuntu/ trusty-security main restricted
  deb http://mirrors.sohu.com/ubuntu/ trusty-updates main restricted
  deb http://mirrors.sohu.com/ubuntu/ trusty-proposed main restricted
  deb http://mirrors.sohu.com/ubuntu/ trusty-backports main restricted

  deb-src http://mirros.sohu.com/ubuntu/ trusty main restricted
  deb-src http://mirrors.sohu.com/ubuntu/ trusty-security main restricted
  deb-src http://mirrors.sohu.com/ubuntu/ trusty-updates main restricted
  deb-src http://mirrors.sohu.com/ubuntu/ trusty-proposed main restricted
  deb-src http://mirrors.sohu.com/ubuntu/ trusty-backports main restricted

  公司采用代理访问外网,于是还得在/etc/apt/apt.conf中加上代理的设置,否则无法更新源,也就无法安装第三方软件:

  Acquire::http::Proxy "http://username:passwd@proxyhost:proxyport";

 【乱码处理】

  由于安装时候选择了中国区域(locale zh_CN.UTF-8),因此在VirtualBox的窗口中直接执行命令的提示信息可能是乱码。对于Server,我们一般是不会直接通过其主机显示 器登录使用的,都是通过终端访问,但在未安装和开启ssh服务和未配置端口转发前,我们只能先凑合这个窗口了。可先将/etc/default /locale中的LANGUAGE由"zh_CN:zh"改为"en_US:en", logout后重新登录就可以看到非乱码的英文提示信息了。

【安装VirtualBox增强组件】

  Ubuntu Server默认是不安装图形桌面的,只有一个命令行窗口,连鼠标都无法使用。因此增强组件安装的意义没有桌面系统那么强烈。我能想到的只有“共享目录”这一个功能有些用处。

  安装方法也不难,按下面步骤逐步操作即可:

  sudo apt-get install build-essential linux-headers-$(uname -r) dkms gcc g++
  sudo mnt /dev/cdrom /mnt
  cd /mnt
  sudo bash ./VBoxLinuxAdditions.run

  如果结果都是"done",重启后就ok了。

【安装ssh服务】

    ssh服务由openssh-server提供:
    sudo apt-get openssh-server
   
   安装成功后,ssh server服务就会自动启动起来。

   不过我们还是需要修改一些配置,比如允许Root登录:打开/etc/ssh/sshd_config,将PermitRootLogin后面的内容改为yes。
   
【设置端口转发】

  前面说过,对于Server,我们更多是在其他主机上通过ssh或telnet远程访问该Server并执行各种操作。由于这里是VirtualBox安 装的虚拟机,其他主机无法看到这台Server,我们需要设置端口转发将外部访问的数据转发给这个内部虚拟Server。

  我们通过VirtualBox软件提供的图形界面即可完成这个操作:
    1、“设置”这个虚拟机
    2、在“网络”标签中,点击“端口转发”按钮,进入端口转发规则添加窗口。
    3、添加一条规则:
          名称:ssh-rules
          协议:TCP
          主机IP、子系统IP可以为空。
          主机端口:2222
          子系统端口:22
   4、配置结束

    配置结束后,我们在宿主机上netstat -an|grep 2222,可以看到VirtualBox增加了该端口2222的监听。

  现在我们就可以在其他机器上通过ssh -l tonybai 宿主机ip -p 2222的方式登录到我们新安装的这台虚拟Server了。

  
二、安装docker

docker目前的最新版本号是1.2.0,但14.04源中的docker还是正式稳定版1.0之前的版本,显然这是无法满足我的要求的。我们只能另外添加docker源来安装最新版docker。

  【安装docker】

    我们在/etc/apt/sources.list中加入下面这个源:
       deb http://mirror.yandex.ru/mirrors/docker/ docker main
  
    执行apt-get update。

    sudo apt-get install lxc-docker

正在读取软件包列表… 完成
正在分析软件包的依赖关系树      
正在读取状态信息… 完成      
将会安装下列额外的软件包:
  aufs-tools cgroup-lite git git-man liberror-perl lxc-docker-1.2.0
建议安装的软件包:
  git-daemon-run git-daemon-sysvinit git-doc git-el git-email git-gui gitk
  gitweb git-arch git-bzr git-cvs git-mediawiki git-svn
下列新软件包将被安装:
  aufs-tools cgroup-lite git git-man liberror-perl lxc-docker lxc-docker-1.2.0
升级了 0 个软件包,新安装了 7 个软件包,要卸载 0 个软件包,有 59 个软件包未被升级。
需要下载 7,477 kB 的软件包。
解压缩后会消耗掉 35.4 MB 的额外空间。
您希望继续执行吗? [Y/n] y

  这个源里的docker居然是最新版。于是安装之。安装后,我们执行docker version来确认一下安装是否成功。

  tonybai@ubuntu-Server-14:~$ docker version
Client version: 1.2.0
Client API version: 1.14
Go version (client): go1.3.1
Git commit (client): fa7b24f
OS/Arch (client): linux/amd64
2014/09/26 13:56:53 Get http:///var/run/docker.sock/v1.14/version: dial unix /var/run/docker.sock: permission denied

  【为docker设置http代理】

    在公司内使用代理才能访问到外网,于是我们也需要为docker命令设置代理以使其顺利执行命令。

    我们安装的docker实际上分为两部分,docker命令行和docker daemon。两者是C/S结构,docker命令行将用户的请求转发给docker daemon,后者会真正与外部通信完成各种操作。

    于是我们可以这样为docker daemon设置http_proxy:
    sudo service docker stop
    sudo http_proxy='http://user:passwd@proxyhost:port' docker -d &

    这样设置启动后,我们可以通过下面命令测试设置是否ok:

      sudo docker search ubuntu

    如果你看到下面信息,说明设置成功了:

    tonybai@ubuntu-Server-14:~$ sudo docker search ubuntu
[info] GET /v1.14/images/search?term=ubuntu
[b36518a9] +job search(ubuntu)
[b36518a9] -job search(ubuntu) = OK (0)
NAME                                             DESCRIPTION                                     STARS                                   OFFICIAL   AUTOMATED
ubuntu                                           Official Ubuntu base image                      709                                     [OK]      
dockerfile/ubuntu                                Trusted automated Ubuntu (http://www.ubunt…   24                                                 [OK]
crashsystems/gitlab-docker                       A trusted, regularly updated build of GitL…   20                                                 [OK]
ubuntu-upstart                                   Upstart is an event-based replacement for …   13                                      [OK]      

… ….

 

Cocos2d-x集成Amazon内购和GameCircle服务

No Comments

由于种种原因,这篇文章已经拖延了N多时间了。今天花了些时间把如何在Cocos2d-x(我用的版本是2.2.2)游戏中集成Amazon内购GameCircle服务(仅适用于Android版本)整理一下,发出来,作备忘。

之前在做“手指足球世界杯2014”时,想给这款小游戏加上内购(In-App Purchasing)和积分榜(ScoreBoard)功能。说到Android手机游戏的内购,人们第一时间想到的就是Google Play,不过悲催的是,Google Play在国内各种无法访问,行货机也不预装,其相关Service的测试十分困难,翻看了一些集成Google Game Service的文章,其过程坎坷之程度让人望而却步。于是我将目光转而投向了Amazon Game Service。亚马逊的游戏服务起步要晚些,成熟性肯定不如Google,但在国内来说也不失为另一个不错的选择,Google虽好,但访问不了有啥 法。但似乎国内同行使用Amazon游戏服务的并不多,度娘上相关中文资料甚少。但从Amazon发布的数据来看,其市场正在逐步扩大,并紧紧跟随 Google Play的脚步。

之前用kindle paperwhite时在amazon.com上注册了一个国际帐号,这次正好用这个。不过你要使用Amazon的Game Service,普通Amazon帐号是不行的。你要升级为Amazon的Developer。申请Developer帐号的过程还是蛮繁琐的,要提交一 堆资料,具体细节我大致忘的差不多了,这里就不说了。按照Amazon网站的提示一步一步做就是了。

有了帐号后,你可以下载Amazon的Game SDK了,这个包有近50M大小,本地解压后可以看到其提供的Android SDK种类:

AmazonSDK/Android$ ls
Ads  AmazonInsights  DeviceMessaging  GameCircle  InAppPurchasing  LoginWithAmazon  Maps  MobileAssociates  README.txt

Ads我之前用的是Google Admob,这里就不再用Amazon的了,我需要的是这里的InAppPurchasing和GameCircle。我们接下来一个一个来说。

* Amazon InAppPurchasing

Amazon支持三种内购类型:Consumables、Entitlements和Subscriptions:
    Consumables就像游戏中的红心、金币等,用户可以多次购买,每次可以买多个,并根据游戏规则,每次消耗若干个以达到某种游戏目的;在哪台设备上购买,就只能在哪台设备上使用。
    Entitlements是某种授权协议,一个用户只需购买一次,即可长期使用某种特权功能,并与设备无关,可在多个设备下授权使用。比如鳄鱼洗澡游戏中购买高级关卡等。
    Subscriptions有订阅的意思,需要某种Entitlements或某种访问权,在一定时间段内绑定有效,到期后自动renew,比如某种杂志的阅读权等。

我只想给游戏增加一些红心功能,一颗红心,可以让游戏者有一次续命的机会,因此我需要实现Consumables型内购。Amazon SDK中提供了Consumeables类内购的Android范例AmazonSDK/Android/InAppPurchasing/samples/SampleIAPConsumablesApp。我们可以参考这个例子来实现我的"红心内购"。

    1、添加依赖的jar包
    在你的游戏proj中添加内购功能所依赖的Amazon SDK jar包,包括AmazonInsights-android-sdk-2.1.26.jar、in-app-purchasing-1.0.3.jar 和login-with-amazon-sdk.jar。

    2、添加源文件
    参照例子,将AppPurchasingObserver.java、AppPurchasingObserverListener.java和MySKU.java拷贝到你的与XXActivity.java同级目录下。

    3、初始化Amazon IAP
   
    在你的XXActivity类中添加如下方法:

    public PurchaseDataStorage purchaseDataStorage;

    private void setupIAPOnCreate() {
        purchaseDataStorage = new PurchaseDataStorage(this);

        AppPurchasingObserver purchasingObserver
              = new AppPurchasingObserver(this, purchaseDataStorage);
        purchasingObserver.setListener(this);

        Log.i(TAG, "onCreate: registering AppPurchasingObserver");
        PurchasingManager.registerObserver(purchasingObserver);
    }

    protected void onCreate(Bundle savedInstanceState){
        … …
        setupIAPOnCreate();
    }

        protected void onResume() {
        super.onResume();
       
        Log.i(TAG, "onResume: call initiateGetUserIdRequest");
        PurchasingManager.initiateGetUserIdRequest();

        Log.i(TAG, "onResume: call initiateItemDataRequest for skus: "
                        + MySKU.getAll());
        PurchasingManager.initiateItemDataRequest(MySKU.getAll());
    }

    4、添加购买方法

    在Cocos2d-x的某个Scene或Layer中实现的购买方法事件的callback,后者通过Jni调用Java静态方法:

    void BuyHeartScene::buyHearts(int number) {
#if (CC_TARGET_PLATFORM == CC_PLATFORM_ANDROID)
    JniMethodInfo t;
    if (JniHelper::getStaticMethodInfo(t, "net/iwobi/game/flickworldcup/FlickWorldCupActivity",
                "onBuyHeartClick", "(I)V")) {
        t.env->CallStaticVoidMethod(t.classID, t.methodID, number);
        if (t.env->ExceptionOccurred()) {
            t.env->ExceptionDescribe();
            t.env->ExceptionClear();
            return;
        }
        t.env->DeleteLocalRef(t.classID);
    }
#endif
    }

    该Java方法的实现如下(我这里有五种商品ONEHEART到FIVEHEART):

    public static void onBuyHeartClick(int type) {
        String requestId;
      
        switch (type) {
            case 1:
                requestId = PurchasingManager.initiatePurchaseRequest(MySKU.ONEHEART.getSku());
                break;
            case 2:
                requestId = PurchasingManager.initiatePurchaseRequest(MySKU.TWOHEART.getSku());
                break;
            case 3:
                requestId = PurchasingManager.initiatePurchaseRequest(MySKU.THREEHEART.getSku());
                break;
            case 4:
                requestId = PurchasingManager.initiatePurchaseRequest(MySKU.FOURHEART.getSku());
                break;
            case 5:
                requestId = PurchasingManager.initiatePurchaseRequest(MySKU.FIVEHEART.getSku());
                break;
            default:
                requestId = PurchasingManager.initiatePurchaseRequest(MySKU.ONEHEART.getSku());
                break;
        }

        PurchaseData purchaseData = ((FlickWorldCupActivity)context).purchaseDataStorage
                .newPurchaseData(requestId);
        Log.i(TAG, "onBuyHeartClick: requestId (" + requestId
                + ") requestState (" + purchaseData.getRequestState() + ")");
    }

    5、修改各种回调方法

    将SampleIAPConsumablesApp/src/com/amazon/sample/iap/consumable /MainActivity.java中的onPurchase为前缀名的方法以及onGetUserIdResponseSuccessful挪到你的 Activity源文件中。这些方法绝大部分是不需要修改的,除非你不喜欢例子中日志输出的格式,或是想用toast之类的提示方式改造各种 callback的结果显示方式。

    这里我主要修改了一个方法:onPurchaseResponseSuccess。该方法在购买成功后被调用,我们在这个事件发生时更新Scene或Layer的显示(updateHeartInScene)。

    @Override
    public void onPurchaseResponseSuccess(String userId, String sku,
            String purchaseToken) {
        Log.i(TAG, "onPurchaseResponseSuccess: for userId (" + userId
                + ") sku (" + sku + ")");
        SKUData skuData = purchaseDataStorage.getSKUData(sku);
        if (skuData == null)
            return;

        if (MySKU.ONEHEART.getSku().equals(skuData.getSKU())) {
            updateHeartInScene(1);
        }

        if (MySKU.TWOHEART.getSku().equals(skuData.getSKU())) {
            updateHeartInScene(2);
        }

        if (MySKU.THREEHEART.getSku().equals(skuData.getSKU())) {
            updateHeartInScene(3);
        }

        if (MySKU.FOURHEART.getSku().equals(skuData.getSKU())) {
            updateHeartInScene(4);
        }

        if (MySKU.FIVEHEART.getSku().equals(skuData.getSKU())) {
            updateHeartInScene(5);
        }
    }

    6、AndroidManifest.xml和其他Java文件

    AppPurchasingObserver.java和AppPurchasingObserverListener.java你可以原封不动的使用。MySKU.java可以根据你的内购项目做改造:

    public enum MySKU {

    ONEHEART("net.iwobi.game.flickworldcup.iap.consumable.oneheart", 1),
    TWOHEART("net.iwobi.game.flickworldcup.iap.consumable.twoheart", 1),
    THREEHEART("net.iwobi.game.flickworldcup.iap.consumable.threeheart", 1),
    FOURHEART("net.iwobi.game.flickworldcup.iap.consumable.fourheart", 1),
    FIVEHEART("net.iwobi.game.flickworldcup.iap.consumable.fiveheart", 1);
   
    private String sku;
    private int quantity;

    private MySKU(String sku, int quantity) {
        this.sku = sku;
        this.quantity = quantity;
    }

    public static MySKU valueForSKU(String sku) {
        if (ONEHEART.getSku().equals(sku)) {
            return ONEHEART;
        }
       
        if (TWOHEART.getSku().equals(sku)) {
            return TWOHEART;
        }

        if (THREEHEART.getSku().equals(sku)) {
            return THREEHEART;
        }

        if (FOURHEART.getSku().equals(sku)) {
            return FOURHEART;
        }

        if (FIVEHEART.getSku().equals(sku)) {
            return FIVEHEART;
        }
       
        return null;
    }

    public String getSku() {
        return sku;
    }

    public int getQuantity() {
        return quantity;
    }

    private static Set<String> SKUS = new HashSet<String>();
    static {
        SKUS.add(ONEHEART.getSku());
        SKUS.add(TWOHEART.getSku());
        SKUS.add(THREEHEART.getSku());
        SKUS.add(FOURHEART.getSku());
        SKUS.add(FIVEHEART.getSku());
    }

    public static Set<String> getAll() {
        return SKUS;
    }

 }

 AndroidManifest.xml中在application标签下添加如下配置:
        <receiver android:name="com.amazon.inapp.purchasing.ResponseReceiver" >
            <intent-filter>
                <action
                    android:name="com.amazon.inapp.purchasing.NOTIFY"
                    android:permission="com.amazon.inapp.purchasing.Permission.NOTIFY" />
            </intent-filter>
        </receiver>
 有了以上代码,我们的内购就可以运行起来了。
   

* 内购测试

使用Amazon In-app Purchasing API一个最大好处就是测试简单。Amazon提供一个本地测试程序Amazon App Tester(安装到Android模拟器中),可以模拟内购Server,SDK自动判断当前场景,如果是测试,你的集成了内购SDK的游戏将连接本地 测试程序完成内购流程。通过在本地测试程序中设置模拟不同的内购流程,我们可以轻松完成测试。

你需要给Amazon App Tester提供一个名为amazon.sdktester.json的文件,这样Amazon App Tester可以知道你的游戏有哪些内购项目,并模拟出这些内购项目。这个json文件可以自行编辑,也可以在Amazon deveoper网站上生成下载。

我直接将内购项目添加到我的Amazon帐号的游戏应用下面,一共五个,添加成功后,下载json文件。将该文件放在模拟器的/mnt/sdcard下,绝对路径为/mnt/sdcard/amazon.sdktester.json。

之后,启动App Tester,再启动你的游戏,点击内购项目,看看是否能购买成功。

* 内购上线

按照Amazon官方说法,SDK会自动区分测试场景和正式场景,因此通过App Tester测试的游戏在发布后,理论上内购是没有问题的。不过我上线后还是遇到了问题,即点击购买某个项目后,游戏没有任何反应,等了若干分钟都是这 样。我将这个问题反馈给Amazon Support,得到的答复居然是游戏代码没有问题,他们测试了若干中机型,都可以打开内购页面,并进行内购。只是有时内购页面打开有些延迟,但都能打 开。看到这里,我猜是否又是大陆网络的问题呢!不管它了,至少通过Amazon Support的回复可以证明我的代码是ok的。只能希望美国人民多多购买我的内购项目了^_^。

* Amazon游戏圈

想给游戏增加成就榜和成就提交功能,如果自己实现服务端,显然麻烦,工作量大不说,还得维护一个Server。但市面上提供这类服务的游戏平台不多。 Google Play的游戏Service提供这种服务,不过还是上面提到的原因,我与Google的这个服务无缘啊。Amazon Game SDK后期推出了GameCircle服务。

GameCircle目前提供achievements, leaderboards和Whispersync三种特性:
    achievements就是奖励机制,帮助游戏提高玩家粘性。
    leaderboards类似于积分榜,可以用于提交玩家积分以及显示玩家的全球排名。
    Whispersync是一种数据游戏同步服务,同步玩家进度,保寸玩家个性化数据等。

这里我要用到的是leaderboards。

    1、建立GameCircle
    使用游戏圈前,你需要在Amazon官方的Amazon Apps & Services Developer Console下创建属于你的Game Circle,然后创建一个LeaderBoard,设置LeaderBoard属性。SDK中提供了GameCircle的Demo:AmazonSDK/Android/GameCircle

    2、导入jar包,设置AndroidManifest.xml
    要想使用GameCircle,我们需要导入相应的SDK jar包:gamecirclesdk.jar。

    在AndroidManifest.xml中,需要在application标签下添加以下配置:

                <activity
            android:name="com.amazon.ags.html5.overlay.GameCircleUserInterface"
            android:hardwareAccelerated="false"
            android:theme="@style/GCOverlay" >
        </activity>
        <activity
            android:name="com.amazon.identity.auth.device.authorization.AuthorizationActivity"
            android:allowTaskReparenting="true"
            android:launchMode="singleTask"
            android:theme="@android:style/Theme.NoDisplay" >
            <intent-filter>
                <action android:name="android.intent.action.VIEW" />

                <category android:name="android.intent.category.DEFAULT" />
                <category android:name="android.intent.category.BROWSABLE" />

                <data
                    android:host="net.iwobi.game.flickworldcup"
                    android:scheme="amzn" />
            </intent-filter>
        </activity>
        <activity
            android:name="com.amazon.ags.html5.overlay.GameCircleAlertUserInterface"
            android:hardwareAccelerated="false"
            android:theme="@style/GCAlert" >
        </activity>

        <receiver
            android:name="com.amazon.identity.auth.device.authorization.PackageIntentReceiver"
            android:enabled="true" >
            <intent-filter>
                <action android:name="android.intent.action.PACKAGE_INSTALL" />
                <action android:name="android.intent.action.PACKAGE_ADDED" />

                <data android:scheme="package" />
            </intent-filter>
        </receiver>

   
        这些配置中需要的res,可以从AmazonSDK/Android/GameCircle/GameCircleSDK/res/中找到并copy到你的project中。

    3、初始化GameCircle

    GameCircleSDK这个Demo中没有提供太多源码,src目录下是空的。因此我们只能参考Amazon Developer站点上页面上的说明一步步的添加和调整我们的代码了。

    在你的XXActivity类中,我们添加如下方法:

    //reference to the agsClient
    public AmazonGamesClient agsClient;
    
    AmazonGamesCallback callback = new AmazonGamesCallback() {
            @Override
            public void onServiceNotReady(AmazonGamesStatus status) {
                Message msg = new Message();
                switch (status) {
                // The SDK failed to initialize correctly.
                case CANNOT_INITIALIZE:
                    Log.i(TAG, "onServiceNotReady: CANNOT_INITIALIZE");
                    msg.obj = "Can not initialize Amazon Game Services";
                    break;

                // The SDK is in the process of initializing.
                case INITIALIZING:
                    Log.i(TAG, "onServiceNotReady: INITIALIZING");
                    msg.obj = "Initializing Amazon Game Services";
                    break;

                // The device not registered with an account
                case NOT_AUTHENTICATED:
                    Log.i(TAG, "onServiceNotReady: NOT_AUTHENTICATED");
                    msg.obj = "The Device does not registered with an account";
                    break;

                // The game is not authorized to use this service.
                case NOT_AUTHORIZED:
                    Log.i(TAG, "onServiceNotReady: NOT_AUTHORIZED");
                    msg.obj = "Not authorized to use Amazon Game Services";                   
                    break;
                }
               
                //unable to use service
                msg.what = 21;               
                notifyHandler.sendMessage(msg);
            }
            @Override
            public void onServiceReady(AmazonGamesClient amazonGamesClient) {
                agsClient = amazonGamesClient;
             
                //ready to use GameCircle
                if (agsClient != null)
                    Log.i(TAG, "on AmazonGamesCallback: call onServiceReady, agsClient init ok");
                else
                    Log.i(TAG, "on AmazonGamesCallback: call onServiceReady, agsClient init failed");
            }
    };
    
    //list of features your game uses (in this example, achievements and leaderboards)
    EnumSet<AmazonGamesFeature> myGameFeatures = EnumSet.of(
            AmazonGamesFeature.Leaderboards);

    protected void onResume() {
        super.onResume();
       
        … …
        AmazonGamesClient.initialize(this, callback, myGameFeatures);
    }

        public void onPause() {
        super.onPause();
        if (agsClient != null) {
            agsClient.release();
        }
    }

   
    4、提交成就积分

    当玩家结束游戏时,可以选择将此次的高分上传到leaderboards上。游戏中应对积分提交的代码也在XXActivity中。

    public static void onSubmitScoreToLeaderBoard(int score) {
        if (((FlickWorldCupActivity)context).agsClient == null) {
            Message msg = new Message();
            msg.what = 21;
            msg.obj = "Unable to use Amazon Game Services";
            notifyHandler.sendMessage(msg);
            return;
        }
       
        LeaderboardsClient lbClient = ((FlickWorldCupActivity)context).agsClient.getLeaderboardsClient();
        AGResponseHandle<SubmitScoreResponse> handle = lbClient.submitScore("FlickWorldCupTopScore", score);
         
        // Optional callback to receive notification of success/failure.
        handle.setCallback(new AGResponseCallback<SubmitScoreResponse>() {
         
            @Override
            public void onComplete(SubmitScoreResponse result) {
                if (result.isError()) {
                    // Add optional error handling here.  Not strictly required
                    // since retries and on-device request caching are automatic.
                    Message msg = new Message();
                    msg.what = 22;
                    msg.obj = "Submit Score to LeaderBoard Failed!";
                    notifyHandler.sendMessage(msg);
                } else {
                    // Continue game flow.
                    Message msg = new Message();
                    msg.what = 23;
                    msg.obj = "Submit Score to LeaderBoard OK!";
                    notifyHandler.sendMessage(msg);
                }
            }
        });       
    }

    如果仅是查看积分排行,可以用下面这个方法:

    public static void onShowLeaderBoardOverlay() {
        if (((FlickWorldCupActivity)context).agsClient == null) {
            Message msg = new Message();
            msg.what = 21;
            msg.obj = "Unable to use Amazon Game Services";
            notifyHandler.sendMessage(msg);
            return;
        }
       
        LeaderboardsClient lbClient = ((FlickWorldCupActivity)context).agsClient.getLeaderboardsClient();
        AGResponseHandle<RequestResponse> handle = lbClient.showLeaderboardOverlay("FlickWorldCupTopScore");
       
        handle.setCallback(new AGResponseCallback<RequestResponse>() {
            
            @Override
            public void onComplete(RequestResponse result) {
                if (result.isError()) {
                    // Add optional error handling here.  Not strictly required
                    // since retries and on-device request caching are automatic.
                    Log.i(TAG, "onShowLeaderBoardOverlay – onComplete: Show LeaderBoard Request Failed!");
                }
            }
        });     
    }

   
* 游戏圈上线

游戏圈无法在本地进行测试,只能在真实的游戏圈中测试代码是否ok。不过Amazon的游戏圈提供了管理功能,在测试后发布前可将游戏圈 leaderboard的值reset。游戏圈leaderboard发布后,你就可以使用leaderboard了。游戏圈功能在国内访问是没有任何问 题的,查看积分榜,提交分数到积分榜都很顺畅。

* 小结

Amazon游戏SDK在国内的应用估计比较小众,大家可能更多的选择用Google Play提供的服务或是AppStore的,但Amazon毕竟为游戏开发者提供了一个选择(而且是完全免费的哦),另外Amazon的Support对 提交问题的反馈较为及时(无论是mail还是forum上的提问),基本24小时内就会有答复。各种设施的发布也比较快,有时候3-4个小时即可生效。

目前Amazon Game SDK的资料多为英文,且集中在Amazon官方站点以及官方维护的support论坛中。遇到问题,亚马逊的论坛是第一选择。

世界足球的那个“王”还会出现吗?

No Comments

准球王梅西最终没能将巴西世界杯的决赛赛场变成自己到加冕地,潘帕斯雄鹰阿根廷连续第三届世界杯被德意志战车践踏,让我这个老阿迷痛心不已。

足球是一种信仰,在足球这个信仰的世界里有神,更要有王。但现代足球趋向整体的战术体系让类似贝利、马拉多纳那样的“王”的出现日益困难。足球界、球迷们 实际上都期望新“王”的诞生,这样才能带来更多的信仰满足感和成就感。因此每当有天赋异秉的球员出现时,大家都会给予足够的关注目光。梅西就是从2005 年世青赛一直被关注到今天上午的决赛赛场的。但就是像梅西这样50年不遇的足球天才也没能最终加冕为像马拉多纳那样的“王”,世界足球的“王”依旧缺失!

我们不禁要问,足球界的那个“王”还会出现吗?现实似乎给了我们比较悲观的答案。虽说很多人认为球王已不再需要世界杯去证明,但传统认知的惯性还是会影响诸多球迷:新球王就应该像贝利和马拉多纳那样拿下大力神杯。这里提到的“王”依旧遵循着传统的认知。

1、整体足球无法诞生“球王”

足球百年历史上公认的球王只有两个:贝利和马拉多纳。两人都来自南美洲,两人都在各自的世界杯舞台上有着被公认是个人英雄式的表演,这让全世界球迷顶礼膜 拜。但在今天整体足球逐渐成为主流趋势的情况下,我们再也很难见到想马拉多纳那样以一己之力带领球队拿到世界杯的表演了。2010年的西班牙,2014年 的德国都是整体足球、团队足球的典型代表,他们虽然夺冠了,但我们无法从中选出一个马拉多纳式的人物,冠军的成绩依靠的是整体的流畅运转,就像我们评价此 次德国队的那样,球队更像一个机器,每个螺丝,每个细节都是关键。

从此次世界杯的表现来看,要说不够“整体”的,还真的只有阿根廷了,这届梅西的表现其实已经十分接近马拉多纳当年的作用了,没能成王十分可惜。巴西队和德国、西班牙比起来,整体性也有不足,似乎具有诞生球王的潜质。但巴西、阿根廷在未来真的能诞生新王吗?我们继续分析。

2、巴西、阿根廷足球的"衰落"

2002年巴西在亚洲夺冠,让人们看到南美足球似乎在恢复统治力。但随着德国、西班牙对青训体系的计划、投入以及严格执行,西班牙、德国从2002年后诞 生了一大批年轻有天赋的足球青年,在2010、2014年,西班牙和德国先后捧杯就是一流青训计划带来的结果。反观这一时段的巴西和阿根廷队,人才青黄不 接。巴西中前场再无3R组合的豪华,阿根廷更加悲惨,不仅后卫趋向三流,进攻组织型中场也销声匿迹,唯独锋线还能拿得出手。更让南美球迷担心的是,巴西、 阿根廷本土联赛的水准日渐下滑,从近几次世俱杯的欧美对抗结果即可看出。巴西、阿根廷绝对不缺少天才型球员,缺少的则是像德国那样的长远规划和强有力的执 行。

另外巴阿两国在球员培养和输送方面开始“拜金”,什么样的球员受欧洲球队欢迎就培养什么位置的球员。哪个俱乐部给钱多,就把球员卖给哪个俱乐部。这导致大 量天才球员登陆欧洲的第一站往往是俄罗斯这样的三流联赛,让这些球员失去了向一流球员学习经验的机会,天赋逐渐被磨灭,经验也都是三流的,没法登上一流的 赛场上,逐渐变得平庸。另外南美球员由于分布在不同国家联赛,导致打法很难统一,一到国家队磨合起来十分困难,间接削弱了国家队的战斗力。

3、欧洲足球是“反球王”模式的

欧洲依旧是世界足球的中心,这有利于欧洲国家的球员在一支球队内磨合和统一风格。比如2010的西班牙,以巴萨为班底;本届的德国以拜仁为班底。核心球员 在俱乐部里配合默契娴熟,到了国家队基本不需要磨合就可以发挥出100%战力,让整体足球体现的淋漓尽致。但这种技术领先、团队合作的欧洲足球是反球王模 式的,这在相当大的程度上阻止了像阿根廷、巴西这样具有“王”诞生条件的国家队站在世界杯的最高领奖台上。

综上三点,世界足球“新球王”诞生真的不再乐观。也许我们依旧能看到像齐达内这样的神,但短时间内很难再看到马拉多纳这样的王的出现。梅西在这届世界杯上 已经尽力了,但依旧无法称王,我实在难以想象得到未来几十年中还能有像梅西这样天赋异秉的足球天才的出现。巴西、阿根廷足球体系的衰落也让王的出现更为难 上加难。

Cocos2d-x屏幕适配之Sprite绘制原理

8 Comments

手机(智能终端)游戏绝大多数为全屏(Full Screen)显示,这样开发人员在制作游戏时势必要考虑不同手机(智能终端)屏幕大小、宽高比的不同给游戏画面带来的影响,并且要将这种影响降低到最 小,努力使用不同终端的游戏玩家拥有几乎相同的游戏画面体验。为此各种游戏引擎在屏幕适配方面都给出了自己的方案,Cocos2d-x也不例外。 在Cocos2d-x官网Wiki上特地撰写了一篇讲解Cocos2d-x多屏幕适配原理的文章“Detailed explanation of Cocos2d-x Multi-resolution adaptation”。

这里我们以Cocos2d-x引擎(基于2.2.2版本)自带的Sample项目HelloCpp(cocos2d-x-2.2.2/samples/Cpp/HelloCpp)为例,直观的看看这个方案带来的好 处。首先,我们对HelloCpp项目做些许改造:
    – 注释掉AppDelegate.cpp中applicationDidFinishLaunching下的pEGLView->setDesignResolutionSize(designResolutionSize.width, designResolutionSize.height, kResolutionNoBorder);
    – 仅使用Resource/iphone下的资源,即仅searchPath.push_back(smallResource.directory); 这里我们有一张480×320分辨率大小PNG文件。
    – 通过改变proj.linux/main.cpp中的eglView->setFrameSize(960, 640);来改变屏幕参数。(用linux工程模拟甚为方便,编译和运行占用资源小,极为迅捷,效果与Android平台是等 效的)

我们对比一下以下三种条件下的游戏Demo显示结果:
    1) 屏幕大小480×320,未做任何屏幕适配工作,不调用pEGLView->setDesignResolutionSize。
    2) 屏幕大小960×640,未做任何屏幕适配工作,不调用pEGLView->setDesignResolutionSize。
    3) 屏幕大小同为960×640,按照上面Cocos2d-x屏幕适配指南Wiki中的做法,调用pEGLView->setDesignResolutionSize(480, 320);

如我们所料,我们得到三个截然不同的结果。

第一种情况,我们所得到的游戏屏幕截图如下:

第二种情况,我们所得到的游戏屏幕截图如下:

第三种情况,我们所得到的游戏屏幕截图如下:

第一种情况是最理想的情况,屏幕大小与背景图片大小相同,如我们所愿,屏幕与背景图片吻合的天衣无缝。
第二种情况显然是模拟我们初次遇到问题的场景。屏幕Size扩大为原先的二倍,在资源没有变化的情况下,我们发现480×320大小的背景图片没 有铺满屏幕,仅仅是居中显示,并在四周露出较多”黑边“,这显然不是我们想要的。
第三种情况,也就是我们按照官方屏幕适配方案调整后得到的结果,在资源依旧不变的情况下,我们得到了相对令人满意的结果:背景图片恰如其分的铺满 整个屏幕,比例正确。这样我们用一套资源就可以同时适配两个屏幕了:480×320、960×640。这两种终端的玩家至少不会对我们的游戏心生 抱怨之情^_^。

当然在遇到第二种情况的时候,你也大可再准备一套新资源,比如一张960×640的背景图片。在480×320手机上,使用480×320的图 片;在960×640的手机上,使用960×640的背景图片。但这种方法的弊端至少有三:
    – 包大了:游戏的安装包Size急剧变大。
    – 活儿多了:因适配屏幕种类太多而制作大量的图片。
    – 新屏幕出来咋办:如果某个厂家突然于某天出品一款手机,其分辨率与以往市面上的所有手机均不同,那你的游戏因没有对应的资源,肯定无法很好适配该手机,导 致较差用户体验。

为此,适配屏幕唯一的出路似乎只有按照官方推荐的方案进行了,当然适当结合有限种类的资源也许可以更好的提升游戏体验。

如果仅仅从游戏制作角度来看,我们找到了可以适配屏幕的方法就可以了,没有必要刨根问底。甚至当有人问起来:为何 setDesignResolutionSize后,背景图片就可以充满屏幕了呢?我们可以回答:“引擎对精灵进行了缩放,就是这样”。但对于上 面的背景精灵来说,真的是我们理解的普通意义上的“精灵缩放(Scale)吗?本着“知其然,也要知其所以然”的精神,这里对引擎如何对 Sprite进行绘制进行了一番研究,我还真发现了一些与我之前理解差异较大的“深奥”原理,这里与大家一起分享一下。

一、绘制参数初始化

我们还是从代码开始,了解一下引擎绘制参数的初始化工作是如何做的、在哪里做的,为后续的分析做些铺垫。这里以Cocos2d-x 2.2.2 Android平台为例。关于Cocos2d-x 2.2.2 Android平台的引擎粗线条启动流程分析,可以参考《Hello,Cocos2d-x》这篇文章。看完这篇文章,你就会知道我们这次应该从Java_org_cocos2dx_lib_Cocos2dxRenderer_nativeInit开 始。

// samples/Cpp/HelloCpp/proj.android/jni/hellocpp/main.cpp
void Java_org_cocos2dx_lib_Cocos2dxRenderer_nativeInit(
               JNIEnv*  env, jobject thiz, jint w, jint h)
{
    if (!CCDirector::sharedDirector()->getOpenGLView())
    {
        CCEGLView *view = CCEGLView::sharedOpenGLView();
        view->setFrameSize(w, h);

        AppDelegate *pAppDelegate = new AppDelegate();
        CCApplication::sharedApplication()->run();
    }
    … …
}

这里是引擎部分初始化的起点:CCDirector和CCEGLView先后完成创建与初始化。接下来我们分别看一下这两个过程,我们主要关 注与绘制参数设置相关的内容:

bool CCDirector::init(void)
{
    setDefaultValues();

    … …
    m_obWinSizeInPoints = CCSizeZero;

    m_pobOpenGLView = NULL;

    m_fContentScaleFactor = 1.0f;
    … …
    return true;
}

void CCDirector::setDefaultValues(void)
{
    CCConfiguration *conf =
     CCConfiguration::sharedConfiguration();
    … …
    // GL projection
    const char *projection =
        conf->getCString("cocos2d.x.gl.projection",
                         "3d");
    if( strcmp(projection, "3d") == 0 )
        m_eProjection = kCCDirectorProjection3D;
    … …
}

由于conf中没有配置“cocos2d.x.gl.projection”,因此projection使用了 getCString传入的默认值:"3d",m_eProjection则被赋值为kCCDirectorProjection3D。

CCEGLView的创建更为简单:

CCEGLView::CCEGLView()
{
    initExtensions();
}

但背后真正发挥关键作用的是其父类CCEGLViewProtocol。

CCEGLViewProtocol::CCEGLViewProtocol()
: m_pDelegate(NULL)
, m_fScaleX(1.0f)
, m_fScaleY(1.0f)
, m_eResolutionPolicy(kResolutionUnKnown)
{
}

这里我们看到了三个重要的字段:m_fScaleX、m_fScaleY以及m_eResolutionPolicy,这三个字段对于后续屏 幕适配起到至关重要的作用。

nativeInit中的view->SetFrameSize(w, h)用于设置的屏幕物理分辨率,如果你的手机是960×640分辨率的,那FrameSize就是960×640。

void CCEGLViewProtocol::setFrameSize(float width,
                                     float height)
{
    m_obDesignResolutionSize
      = m_obScreenSize
      = CCSizeMake(width, height);
}
初始情况下,CCEGLViewProtocol将“设计分辨率”m_obDesignResolutionSize也设置为与 FrameSize or m_obScreenSize同等大小。

我们回到游戏逻辑层代码AppDelegate.cpp,我们知道游戏逻辑的入口在这里,最初的参数初始化是在为Director设置 GLView实例时进行的:

bool AppDelegate::applicationDidFinishLaunching() {
    // initialize director
    CCDirector* pDirector = CCDirector::sharedDirector();
    CCEGLView* pEGLView = CCEGLView::sharedOpenGLView();

    pDirector->setOpenGLView(pEGLView);
    CCSize frameSize = pEGLView->getFrameSize();
    … …
}

void CCDirector::setOpenGLView(CCEGLView *pobOpenGLView)
{
        m_pobOpenGLView = pobOpenGLView;

        // set size
        m_obWinSizeInPoints =
           m_pobOpenGLView->getDesignResolutionSize();
        … …

        if (m_pobOpenGLView)
        {
            setGLDefaultValues();
        }

        CHECK_GL_ERROR_DEBUG();
        … …
    }
}

由于尚未调用setDesignResolutionSize,因此m_obWinSizeInPoints的值与FrameSize大小相 同。

setGLDefaultValues最为关键,这是我们第一次遇到该函数,该方法用于初始化一些OpenGL的参数,建立好后续 OpenGL操作时所需要的各种数据结构。

void CCDirector::setGLDefaultValues(void)
{
    … …
    setAlphaBlending(true);
    setDepthTest(false);
    setProjection(m_eProjection);
    // set other opengl default values
    glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
}

glClearColor(0.0f, 0.0f, 0.0f, 1.0f);设置初始颜色为黑色,alpha为1.0f,即完全不透明。setProjection是实际上绘制参数设置的核心。

void CCDirector::setProjection(ccDirectorProjection kProjection)
{
    CCSize size = m_obWinSizeInPoints;

    setViewport();
   
    switch (kProjection)
    {
    case kCCDirectorProjection3D:
        {
            float zeye = this->getZEye();

            kmMat4 matrixPerspective, matrixLookup;

            kmGLMatrixMode(KM_GL_PROJECTION);
            kmGLLoadIdentity();

            … …

            // issue #1334
            kmMat4PerspectiveProjection( &matrixPerspective,
                   60,
                  (GLfloat)size.width/size.height,
                   0.1f, zeye*2);

            kmGLMultMatrix(&matrixPerspective);

            kmGLMatrixMode(KM_GL_MODELVIEW);
            kmGLLoadIdentity();
            kmVec3 eye, center, up;
            kmVec3Fill( &eye, size.width/2,
                   size.height/2, zeye );
            kmVec3Fill( &center, size.width/2,
                   size.height/2, 0.0f );
            kmVec3Fill( &up, 0.0f, 1.0f, 0.0f);
            kmMat4LookAt(&matrixLookup, &eye,
                         &center, &up);
            kmGLMultMatrix(&matrixLookup);
        }
        break;
        … …
    }

    m_eProjection = kProjection;
    ccSetProjectionMatrixDirty();
}

由于前面m_eProjection已经被赋值为kCCDirectorProjection3D,因此我们只分析 kCCDirectorProjection3D这个case分支。该函数大致进行设置的顺序是:设置视口变换(ViewPort)、设置投影变换矩阵和 设置模型视图变换矩阵。我们分别来看:

 * 设置视口(ViewPort)

void CCDirector::setViewport()
{
    if (m_pobOpenGLView)
    {
        m_pobOpenGLView->setViewPortInPoints(0, 0,
              m_obWinSizeInPoints.width,
              m_obWinSizeInPoints.height);
    }
}

void CCEGLViewProtocol::setViewPortInPoints(float x ,
                     float y , float w , float h)
{
    glViewport((GLint)(x * m_fScaleX
               + m_obViewPortRect.origin.x),
               (GLint)(y * m_fScaleY
               + m_obViewPortRect.origin.y),
               (GLsizei)(w * m_fScaleX),
               (GLsizei)(h * m_fScaleY));
}

这是我们遇到的第一个OpenGL概念:设置视口变换,关于视口变换究竟起到什么作用,后续会细说。

 * 设置“投影变换”矩阵参数

 kmMat4PerspectiveProjection( &matrixPerspective, 60,
        (GLfloat)size.width/size.height, 0.1f, zeye*2);
 kmGLMultMatrix(&matrixPerspective);

 * 设置“模型视图变换”矩阵参数

 kmVec3 eye, center, up;
 kmVec3Fill( &eye, size.width/2,
             size.height/2, zeye );
 kmVec3Fill( &center, size.width/2,
             size.height/2, 0.0f );
 kmVec3Fill( &up, 0.0f, 1.0f, 0.0f);
 kmMat4LookAt(&matrixLookup, &eye,
             &center, &up);

至此,引擎的绘制参数初始化设置就OK了,在你调用setDesignResolutionSize之前,这些参数不会被改变。

二、kazmath

Cocos2d-x引擎最底层采用OpenGL ES 2.0进行图形绘制,这样要想搞清楚前面的问题缘由,对OpenGL那一套技术体系至少要有一些直观认识才行。在这之前,我们还要先了解一些 Cocos2d-x深度使用的kazmath库。根据《Cocos2d-x高级开发教程》书 中说: “因为在Cocos2d-x 2.0采用的OpenGL ES 2.0中,而那些OpenGL ES 1.0函数已经不可使用了。但OpenGL ES 2.0已经放弃了固定的渲染流水线,取而代之的是自定义的各种着色器,在这种情况下变换操作通常需要由开发者来维护。所幸引擎也引入了一套第三方库 Kazmath,它使得我们几乎可以按照原来OpenGL ES 1.0所采用的方式进行开发”。

至此,我们大致知道了Kazmath库是用来辅助我们按照OpenGL ES 1.0的方式管理变换矩阵以及做变换操作的,接下来我们一起来看看kazmath库的结构吧:

//cocos2d-x-2.2.2/cocos2dx/kazmath/src/GL/matrix.c

km_mat4_stack modelview_matrix_stack;
km_mat4_stack projection_matrix_stack;
km_mat4_stack texture_matrix_stack;
km_mat4_stack* current_stack = NULL;
static unsigned char initialized = 0;

以上是Cocos2d-x整个引擎生命周期内会用到的与opengl变换矩阵相关的一些全局变量。

kazmath声明了三个变换矩阵的栈,modelview_matrix_stack(模型视图矩阵栈)、 projection_matrix_stack(投影矩阵栈)以及texture_matrix_stack(纹理矩阵栈)。不过Cocos2d-x引 擎只用到了前两个变化矩阵栈。current_stack指向当前所使用的那个变换矩阵栈。

这些栈的初始化在lazyInitialize中:

void lazyInitialize()
{

    if (!initialized) {
        kmMat4 identity; //Temporary identity matrix

        //Initialize all 3 stacks
        //modelview_matrix_stack =
            (km_mat4_stack*) malloc(sizeof(km_mat4_stack));
        km_mat4_stack_initialize(&modelview_matrix_stack);

        //projection_matrix_stack =
            (km_mat4_stack*) malloc(sizeof(km_mat4_stack));
        km_mat4_stack_initialize(&projection_matrix_stack);

        //texture_matrix_stack =
            (km_mat4_stack*) malloc(sizeof(km_mat4_stack));
        km_mat4_stack_initialize(&texture_matrix_stack);

        current_stack = &modelview_matrix_stack;
        initialized = 1;

        kmMat4Identity(&identity);

        //Make sure that each stack has the identity matrix
        km_mat4_stack_push(&modelview_matrix_stack, &identity);
        km_mat4_stack_push(&projection_matrix_stack, &identity);
        km_mat4_stack_push(&texture_matrix_stack, &identity);
    }
}

kmMat4Identify用于初始化“单位矩阵(Indentify Matrix)”,所谓"单位矩阵",指的是对脚线上元素都为1的矩阵。从kmMat4Identify的实现,我们也可以看出这一点:

kmMat4* const kmMat4Identity(kmMat4* pOut)
{
    memset(pOut->mat, 0, sizeof(float) * 16);
    pOut->mat[0] = pOut->mat[5]
     = pOut->mat[10]
     = pOut->mat[15] = 1.0f;

    return pOut;
}

最后,lazyInitialize函数将单位矩阵分别圧入(km_mat4_stack_push)不同的matrix stack。

再回顾一下CCDirector::setProjection,该函数通过kazmath先后设置了 projection_matrix_stack和modelview_matrix_stack的top元素。

   kmGLMatrixMode(KM_GL_PROJECTION);
   kmGLLoadIdentity();
   kmMat4PerspectiveProjection( &matrixPerspective, 60,
     (GLfloat)size.width/size.height, 0.1f, zeye*2);
   kmGLMultMatrix(&matrixPerspective);
  
   kmGLMatrixMode(KM_GL_MODELVIEW);
   kmGLLoadIdentity();
   kmVec3 eye, center, up;
   kmVec3Fill( &eye, size.width/2,
               size.height/2, zeye );
   kmVec3Fill( &center, size.width/2,
               size.height/2, 0.0f );
   kmVec3Fill( &up, 0.0f, 1.0f, 0.0f);
   kmMat4LookAt(&matrixLookup, &eye,
               &center, &up);
   kmGLMultMatrix(&matrixLookup);

三、精灵绘制

由《Hello,Cocos2d-x》一文我们知道,一旦引擎初始化完毕,就开始了每帧图像的绘制工作,Render Thread在一个“死循环”中反复调用CCDirector的drawScene方法 (CCDisplayLinkDirector::mainLoop中调用了drawScene):

void CCDirector::drawScene(void)
{
    … …
    glClear(GL_COLOR_BUFFER_BIT
           | GL_DEPTH_BUFFER_BIT);
    … …
    kmGLPushMatrix();

    // draw the scene
    if (m_pRunningScene)
    {
        m_pRunningScene->visit();
    }
    … …
    kmGLPopMatrix();
    … …
}

Cocos2d-x采用“渲染树”的方式进行绘制,即先从场景(Scene)的顶层根节点开始,深度优先的递归绘制Child Node。而整个绘制的顶层节点是CCScene。绘制从m_pRunningScene->visit()真正开始。visit是Scene、 Layer、Sprite的共同父类CCNode实现的方法:

void CCNode::visit()
{
    if (!m_bVisible)
    {
        return;
    }
    kmGLPushMatrix();
    … …
    this->transform();
    … …
   
    if(m_pChildren &&
       m_pChildren->count() > 0)
    {
        sortAllChildren();
        // draw children zOrder < 0
        … ..
        // self draw
        this->draw();

        // draw other children nodes
        … …
    } else {
        this->draw();
    }
    … …
    kmGLPopMatrix();
}
   
Visit大致做了这么几件事:
    – 向当前OpenGL变换矩阵栈Push元素
    – 用当前OpenGL变换矩阵栈栈顶元素的变换参数做节点变换
    – 递归绘制zOrder < 0 的子节点
    – 绘制自己
    – 递归绘制其他子节点
    – 从当前OpenGL变换矩阵栈Pop元素

如果你想知道为什么父节点缩放(Scale)、旋转(Rotate)、扭曲(Skew)后,子节点也会跟着父节点同样缩放(Scale)、旋 转(Rotate)、扭曲?其原理就在这里的transform方法中:

void CCNode::transform()
{
    kmMat4 transfrom4x4;

    // Convert 3×3 into 4×4 matrix
    CCAffineTransform tmpAffine
       = this->nodeToParentTransform();
    CGAffineToGL(&tmpAffine,
                 transfrom4x4.mat);

    // Update Z vertex manually
    transfrom4x4.mat[14] = m_fVertexZ;

    kmGLMultMatrix( &transfrom4x4 );
    … …
}

在进入tranform以前,Cocos2d-x做了啥?对了,kmGLPushMatrix():

void kmGLPushMatrix(void)
{
    kmMat4 top;

    lazyInitialize();

    //Duplicate the top of the stack (i.e the current matrix)
    kmMat4Assign(&top, current_stack->top);
    km_mat4_stack_push(current_stack, &top);
}

在引擎初始化后,我们的current_stack是模型视图矩阵栈modelview_matrix_stack。所有设置的初始参数都保 存在该栈的栈顶元素中。在每次Node绘制前,Node都会创建自己的变换矩阵,但这个矩阵不是凭空创造的,从kmGLPushMatrix 可以看出,在当前Node将新创建的矩阵元素圧栈前,它复制了原栈顶元素,也就携带有父节点所有的初始变换信息,也就是说在 km_mat4_stack_push后,栈顶放置的元素其实是原栈顶元素的复制品,而后续所有操作都是基于这个复制品的。这样一来,如果父 节点做了缩放或旋转或扭曲,那这些信息都会作为初始信息作为子节点变换的基础,后续子节点自身的变换参数也都是在这个基础上做出的,最终的矩 阵是transform方法中的kmGLMultMatrix后得出的。真正的矩阵变换计算都在nodeToParentTransform 中,不过要想看懂这个函数,需要对OpenGL有更深入的了解才行,这里略过^_^。

真正绘制Node的方法是CCNode::draw的override方法。CCNode::draw是一个空函数,各个子类 override该方法进行各自的绘制。以CCSprite::draw为例:

void CCSprite::draw(void)
{
    CC_NODE_DRAW_SETUP();

    ccGLBlendFunc( m_sBlendFunc.src, m_sBlendFunc.dst );

    ccGLBindTexture2D( m_pobTexture->getName() );
    ccGLEnableVertexAttribs( kCCVertexAttribFlag_PosColorTex );

#define kQuadSize sizeof(m_sQuad.bl)
    long offset = (long)&m_sQuad;

    // vertex
    int diff = offsetof( ccV3F_C4B_T2F, vertices);
    glVertexAttribPointer(kCCVertexAttrib_Position, 3,
     GL_FLOAT, GL_FALSE, kQuadSize, (void*) (offset + diff));

    // texCoods
    diff = offsetof( ccV3F_C4B_T2F, texCoords);
    glVertexAttribPointer(kCCVertexAttrib_TexCoords, 2,
      GL_FLOAT, GL_FALSE, kQuadSize, (void*)(offset + diff));

    // color
    diff = offsetof( ccV3F_C4B_T2F, colors);
    glVertexAttribPointer(kCCVertexAttrib_Color, 4,
           GL_UNSIGNED_BYTE, GL_TRUE,
           kQuadSize, (void*)(offset + diff));

    glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
    … …
}

这里的draw是一个典型的OpenGL绘制工序。CC_NODE_DRAW_SETUP()将之前的经过若干准备而得到的最终各类变换矩阵 整合并传给OpenGL:

/** @def CC_NODE_DRAW_SETUP
 Helpful macro that setups the GL server state,
 the correct GL program and sets the Model View
 Projection matrix
 @since v2.0
 */
#define CC_NODE_DRAW_SETUP() \
do { \
    ccGLEnable(m_eGLServerState); \
    CCAssert(getShaderProgram(), "No shader program set for this node"); \
    { \
        getShaderProgram()->use(); \
        getShaderProgram()->setUniformsForBuiltins(); \
    } \
} while(0)

void CCGLProgram::setUniformsForBuiltins()
{
    kmMat4 matrixP;
    kmMat4 matrixMV;
    kmMat4 matrixMVP;

    kmGLGetMatrix(KM_GL_PROJECTION, &matrixP);
    kmGLGetMatrix(KM_GL_MODELVIEW, &matrixMV);

    kmMat4Multiply(&matrixMVP, &matrixP, &matrixMV);

    setUniformLocationWithMatrix4fv(m_uUniforms[kCCUniformPMatrix],
                                    matrixP.mat, 1);
    setUniformLocationWithMatrix4fv(m_uUniforms[kCCUniformMVMatrix],
                                    matrixMV.mat, 1);
    setUniformLocationWithMatrix4fv(m_uUniforms[kCCUniformMVPMatrix],
                                    matrixMVP.mat, 1);
    … …
}

经过计算顶点、绑定纹理等步骤后,最终由glDrawArrays完成Node绘制。

四、m_fScaleX和m_fScaleY都是1.0,背景精灵为何被放大?

根据上面的分析,我们了解到“子节点将跟随父节点的缩放而缩放”。据此,我们来分析一下前面提到的屏幕适配例子中的第三种情况,即屏幕大小为 960×640,按照Cocos2d-x屏幕适配指南Wiki中的做法,调用 pEGLView->setDesignResolutionSize(480, 320)。在该情况中,我们得到的结果是480×320大小的背景图片充满了大小为960×640的屏幕窗口,这给我们的直观印象就是背景图片被放大了一 倍。下面我们就尝试用上面的分析来解释一下这个现象。

在这个例子中,渲染树结构如下:
   CCScene
        – CCLayer
            – CCSprite – 背景图精灵

按照之前的理论,背景图精灵自身或父类应该有缩放的设置,比如m_fScaleX = 2.0之类的设置,于是我在代码中输出了Scene、Layer以及Sprite的m_fScaleX和m_fScaleY值。但出乎预料的是,这些 Node子类的两个轴向缩放值都保持了默认值,即1.0f。在代码里翻了半天,也的确没有找到改写Scene、Layer或Sprite Scale的地方。又一想:代码中调用了setDesignResolutionSize,这样CCEGLView的m_fScaleX = m_fScaleY = 2.0f,难道是CCEGLView的m_fScale传递给了CCScene等Node子类,但事实总是残酷的,代表这一联系的代码也始终未被我所找 到,看来继续纠结m_fScale的值设置是无法搞清楚真正原因,应该换换思路了。这里背景图的放大不应该是Node scale值设置的问题,也就是说关键环节不应该在绘制流程,而是在之前的OpenGL变换矩阵参数设置,看来不再深入学习点OpenGL知识,这个问题 就很难搞定了,于是开始翻看《OpenGL编程指南7th》(号称OpenGL红宝书)和《OpenGL超级宝典》(号称OpenGL蓝宝 书)。虽然我的阅读是粗粒度的,但还是收获到了一些答案。

五、OpenGL基础

OpenGL是帮助我们将三维世界的物体转换到二维屏幕上的一组接口。在新技术尚未出现之前,我们的屏幕永远是二维的,即便是现在的3D电影 也是双眼视角二维图像叠加的结果。我们知道“将大象装进冰箱总共分三 步”,将一个三维模型转换到二维屏幕上,OpenGL也规定了相对流水线般的步骤。

OpenGL三维图形的显示流程

三维图形显示流程中,涉及到OpenGL的一个重要操作,那就是“变换(Transformation)”,主要的变换包括模型视图变换 (model-view transformation)、投影变换(projection transformation)以及视口变换(ViewPort transformation)。我们经常用相机模拟来对比OpenGL解决这一问题的过程以及相关概念。

回顾一下我们自己用相机拍照的步骤吧。

第零步,选景。景就是所谓的三维模型或三维物体,或简称模型(Model),就是我们要显示到屏幕上的物体;
第一步,确定相机位置。让相机以一定的距离、高度、角度对准模型。在这里,相机的位置变换,对应OpenGL的“视图变换或叫视点变换 (View Transformation)”。在这一步里(对应上面图中的第二步),我们还可以调整三维物体的相对位置、角度与相机的距离,这就是模型变换 (Modeling Transformation),两种变换达成的效果是相同的,因此总称模型视图变换(Model-View Transformation)。
第二步,选镜头,并调焦。确定图像投影在胶片上的范围以及景深等。这一步叫投影变换(Projection Transformation)。
第三步,冲洗照片。拍摄好的图像放在底片上,但我们需要选择冲洗后最终是放在6寸相纸还是20寸相纸上,显然在不同大小相纸上,图像的显示效 果不同(比如大小)。这个过程叫视口变换(Viewport Transformation)。

三维空间的物体都是用三维坐标描述的,谈到坐标就离不开坐标系,OpenGL中的坐标系就有多种,我们最常用的就是世界坐标系。

世界坐标系是以屏幕中心为原点(0, 0, 0),你面对屏幕,你的右边是x正轴,上面是y正轴,屏幕指向你的为z正轴。无论如何变换,世界坐标系都不动。我们在Cocos2d-x中设置 初始参数时,参数的单位多为世界坐标系中的单位。

视点变换时会涉及到视点坐标系,但这个变换由opengl接口来负责,我们不用过多关心。

绘图坐标系(局部坐标系),当前绘图坐标系是绘制物体时的坐标系。程序刚初始化时,世界坐标系和当前绘图坐标系是重合的,当用 glTranslatef()等变换函数做移动和旋转时,都是改变的当前绘图坐标系,改变的位置都是当前绘图坐标系相对自己的x,y,z轴所做的 改变,改变以后,再绘图时,都是在当前绘图坐标系进行绘图,所有的函数参数也都是相对当前绘图坐标系来讲的。

屏幕坐标系,即终端屏幕上的坐标系,与世界坐标系有不同,它以屏幕左上角的点为原点,向右是x正轴,向下是y正轴,屏幕指向你的为z正轴。

注意视口(Viewport)的设置是以实际屏幕坐标定义了窗口中的区域,长度宽度都是以实际像素为单位。当然引擎在精灵绘图时用 的是绘图坐标系,我们理解原点在左下角即可。

六、Cocos2d-x各种变换矩阵的初始参数设置

前面说过,Cocos2d-x在CCDirector::setProjection中完成了对变换矩阵的初始参数设置,我们逐一来看看这些设置对模型映射后的二维图像有何影响,这也是理解篇头几个问题的关键环节。

  * 投影变换
   
    前面提到过,投影变换相当于调节相机镜头。OpenGL中提供了两种投影方式,一种是正射投影,另一种是透视投影。Cocos2d-x使用的是透视投影 (Perspective Projection)。透视投影是实际人们观察事物的真实反馈,即离视点近的物体大,离视点远的物体小,远到极点即为消失,成为灭点。Cocos2d- x使用的是kmMat4PerspectiveProjection,对应OpenGL中的gluPerspective,该方法创建一个对称透视视景体 (View Volumn),见下图:

gluPerspective的函数原型如下:void gluPerspective(GLdouble fovy,GLdouble aspect,GLdouble zNear, GLdouble zFar);

    参数fovy定义视野在X-Z平面的角度,范围是[0.0, 180.0],也就是上图中的“视角”;
    参数aspect是投影平面宽度与高度的比率;
    参数zNear和Far分别是近远裁剪面沿Z负轴到视点的距离,它们总为正值。
  
Cocos2d-x中是这么设置投影变换矩阵的:

  float zeye = this->getZEye();
  kmMat4PerspectiveProjection( &matrixPerspective, 60, (GLfloat)size.width/size.height, 0.1f, zeye*2);

  float CCDirector::getZEye(void)
  {
    return (m_obWinSizeInPoints.height / 1.1566f);
  }

从参数上来看,
    视角 = 60度
    宽高比 = 设计分辨率的宽高比,
    近平面 = 距离视点0.1f,几乎与视点重合
    远平面 = 距离视点zeye * 2距离。
    视点位置 = 设计分辨率.height / 1.1566f

投影是用来对模型进行截取的,只有在投影变换所建立的平头截体(Frustum,投影的近、远两个截面以及其他四个面构成的立体体)内的模型部分才会被最终映射和显示。我们用下面的图来直观了解一下各个参数在三维空间的概念吧。

显然引擎如此设置投影矩阵的参数是有考虑的:
首先就是投影平头截体的宽高比 = 设计分辨率的宽高比,这样设置使得一切符合设计分辨率宽高比的模型都可以被理想截取。
其次,视角60度,zEye的在Z轴正方向距离世界原点的距离 = (m_obWinSizeInPoints.height / 1.1566f),这里的1.1566f是怎么来的呢?我们沿着X轴负方向向zy平面投影,得到下图:

看这个图,让我想起了初中几何,通过60度的视角,我们可以推断由eye、XZ截断上平面与Y轴的交点、XZ截断下平面与Y轴的交点组成一个等边三角形, 现在我们已知在Zy平面投影中视点与原点的距离为m_obWinSizeInPoints.height / 1.1566f, 我们还知道夹角是60度,我们求一下投影在(z=0,XY平面)的截面高度h。

cos30 = (m_obWinSizeInPoints.height / 1.1566f)/ h
h = (m_obWinSizeInPoints.height / 1.1566f)/cos30 = m_obWinSizeInPoints.height;

我们计算出来的结果是 h = m_obWinSizeInPoints.height = 设计分辨率中的高度分量。这意味这什么呢?Cocos2d-x是2D游戏渲染引擎,针对该引擎的模型的z坐标都是0,因此模型实际上就在xy平面内,也就 是说eye与原点的距离恰好就是eye与模型的距离,而模型可显示区域的最大高度也就是h,即m_obWinSizeInPoints.height。这 个结论会在后续问题分析时发挥作用。

注意虽然这里知道eye在Z轴正方向距离世界原点的距离,但eye的(x, y)坐标在投影设置后依旧无法确认,我们需要在设置模型视图变换时得到eye的(x, y)坐标。

  * 视图变换

    kmGLMatrixMode(KM_GL_MODELVIEW);
    kmGLLoadIdentity();
    kmVec3 eye, center, up;
    kmVec3Fill( &eye, size.width/2, size.height/2, zeye );
    kmVec3Fill( &center, size.width/2, size.height/2, 0.0f );
    kmVec3Fill( &up, 0.0f, 1.0f, 0.0f);
    kmMat4LookAt(&matrixLookup, &eye, &center, &up);
    kmGLMultMatrix(&matrixLookup);

OpenGL原生的视图变换参数设置方法是gluLookAt,在kazmath中对应的方法为kmMat4LookAt。gluLookAt的函数原型是:

    void gluLookAt(GLdouble eyex, GLdouble exey, GLdouble eyez,
       GLdouble centrex, GLdouble centrey, GLdouble centrez,
       GLdouble upx, GLdouble upy, GLdouble upz);

eye的坐标(eyex, eyey, eyez), Cocos2d-x中是这么设置的kmVec3Fill( &eye, size.width/2, size.height/2, zeye )。可以看出eye在xy平面的投影恰好是以屏幕分辨率构成的矩形的中心。

centre坐标,表示的是视线方向,该方向矢量是由eye坐标、centre坐标共同构成的,由eye指向center。Cocos2d-x的设置 kmVec3Fill( &center, size.width/2, size.height/2, 0.0f )。x, y坐标与eye的相同,因此视线平行于Z轴。

最后的up参数可以理解为头顶方向,这里设置为Y轴方向。

可以看出,eye就在投影区的中心,由于投影区的高度为size.height(投影变换时分析得到的),这样根据投影矩阵设置的宽高比,得出该投影区的宽度也恰为size.width。

七、再分析

有了以上关于Cocos2d-x引擎的了解,我们再回过头来用OpenGL的变换原理对篇头的三种情况做分析。

 1) 屏幕大小480×320,未做任何屏幕适配工作,不调用pEGLView->setDesignResolutionSize。结果:背景图充满窗口。

    在这种情况下,各个OpenGL变换矩阵参数值如下:
        eye视点坐标(240, 160, 320/1.1566f);
        投影变换矩阵在xy平面的截面区域恰好是480×320;
        背景图锚点位置(240, 160, 0);

    在这种情况下,截面区域恰与背景图重合,显示在屏幕上后,背景图恰充满窗口,见下图:

   
   
 2) 屏幕大小960×640,未做任何屏幕适配工作,不调用pEGLView->setDesignResolutionSize。结果:背景图未充满窗口,四周有较大黑边。
 
    在这种情况下,各个OpenGL变换矩阵参数值如下:
        eye视点坐标(480, 320, 480/1.1566f);
        投影变换矩阵在xy平面的截面区域是960×640;
        而背景图锚点位置(480, 320, 0);

    因此背景图(480×320)未能完整充满截面区域(960×640),背景图周围将有较大黑边,见下图:
   
     

 3) 屏幕大小同为960×640,按照上面Cocos2d-x屏幕适配指南Wiki中的做法,调用pEGLView->setDesignResolutionSize(480, 320)。结果:背景图放大为原来2倍,充满屏幕窗口。

    在这种情况下,各个OpenGL变换矩阵参数值如下:
        eye视点坐标(240, 160, 320/1.1566f);
        投影变换矩阵在xy平面的截面区域是480×320;
        而背景图锚点位置(240, 160, 0);

    在这种情况下,截面区域恰与背景图重合。但这里需要注意的是现在屏幕是960×640,而截面区域仅仅是480×320,为何映射后,背景图充满屏幕了呢?这里就不能不提到视口的作用了。

    前面说过视口相当于相片,现在我们拍摄出的图片是480×320的,但我们选择的底片Viewport却是960×640的,怎么办,在视口转换 时,OpenGL自动将480×320的图片映射到960×640的底片上,相当于对图像进行的放大。而960×640的视口恰好与屏幕窗口大小一致且坐 标重叠,于是我们就在屏幕上看到了一个铺满屏幕的背景图,见下图:

   

 4) 我们再来说两个有关视口的例子

    以第三种情况为基础,我们修改一下引擎代码,看看视口的作用。
   
    我们手工将CCDirector::setViewport()中的:
        m_pobOpenGLView->setViewPortInPoints(0, 0, m_obWinSizeInPoints.width, m_obWinSizeInPoints.height);
    改为:
        m_pobOpenGLView->setViewPortInPoints(0, 0, m_obWinSizeInPoints.width/2, m_obWinSizeInPoints.height/2);

    这样修改后,Viewport从point(0,0), rect (960×640)变成了point(0,0), rect (480×320)。也就是说用照相机拍出的景物大小是480×320,底片也是480×320,但屏幕是960×640,我们可以将屏幕理解为相框,把 一张480×320的照片,放到960×640大小的相框里,相片只能占据相框的四分之一。这个例子的最终屏幕显示结果见下图:

   

    前面的例子中背景图片size均小于屏幕大小,我们再来举一个资源图片大于屏幕大小的例子,看看经过一系列变换会得到什么样的结果。
   
    首先将CCDirector::setViewport()中的代码恢复原先状态。然后我们准备一张1024×768(>屏幕的960×640)的 背景图片"HelloWorld-1024×768.jpg",修改HelloWorldScene.cpp,将:
    CCSprite* pSprite = CCSprite::create("HelloWorld.png");
    修改为:
    CCSprite* pSprite = CCSprite::create("HelloWorld-1024×768.png");

    注释掉AppDelegate.cpp中的pEGLView->setDesignResolutionSize调用,这样更直观。

    这样修改后,各参数如下:
        eye视点坐标(480, 320, 640/1.1566f);
        投影变换矩阵在xy平面的截面区域是960×640;
        而背景图锚点位置(480, 320, 0);
        Viewport point(0,0), rect (960×640)
   
    由于背景资源图片太大(1024×768),大于我们的投影截面区域960×640,因此模型真正能显示的部分仅仅是投影截面区域中的那960×640范围内的图片。于是显示结果如下:

   

    矩阵变换过程如下:

   

    投影截面区域与视口区域重叠,这里就不再赘述了。

八、CCDirector::m_fContentScaleFactor

决定图像在屏幕上的最终显示结果的因素还有一个,那就是CCDirector::m_fContentScaleFactor。在最初的HelloCpp例子中,我们能看到这样的代码:

    if (frameSize.height > mediumResource.size.height)
    {
        searchPath.push_back(largeResource.directory);
        pDirector->setContentScaleFactor(
          MIN(largeResource.size.height/designResolutionSize.height,
              largeResource.size.width/designResolutionSize.width));
    }
    … …

    可以看出这个contentScaleFactor存储的是资源分辨率与设计分辨率的比值。我们还是用例子来看看该元素对显示的影响。我们在第一种情况的基础上验证。

    第一种情况:屏幕480×320,未调用setDesignResolutionSize,资源大小480×320。结果:图片充满屏幕。

    现在我们增加并使用一个新资源:HelloWorld-960×640.png,这个图片大小960×640,是屏幕大小的二倍,根据上面的分析,我们很容易猜测到最终结果是:只有图片中央区域(480×320)可以显示出来,其余部分被投影矩阵截掉。

    现在我们使用setContentScaleFactor,在AppDelegate.cpp中做如下调用:

    pDirector->setContentScaleFactor(MIN(960/480, 640/320));

    这样我们得到的m_fContentScaleFactor = 2。而我们编译运行后得到的结果是:图片铺满整个屏幕。为什么会这样呢?

    我们在代码中搜索contentScaleFactor,我们找到一些宏和调用:

   
#define CC_CONTENT_SCALE_FACTOR() CCDirector::sharedDirector()->getContentScaleFactor()

CCSize CCTexture2D::getContentSize()
{

    CCSize ret;
    ret.width = m_tContentSize.width / CC_CONTENT_SCALE_FACTOR();
    ret.height = m_tContentSize.height / CC_CONTENT_SCALE_FACTOR();

    return ret;
}

#define CC_RECT_PIXELS_TO_POINTS(__rect_in_pixels__)                                                                        \
    CCRectMake( (__rect_in_pixels__).origin.x / CC_CONTENT_SCALE_FACTOR(), (__rect_in_pixels__).origin.y / CC_CONTENT_SCALE_FACTOR(),    \
            (__rect_in_pixels__).size.width / CC_CONTENT_SCALE_FACTOR(), (__rect_in_pixels__).size.height / CC_CONTENT_SCALE_FACTOR() )

… …

bool CCSprite::initWithTexture(CCTexture2D *pTexture)
{
    CCAssert(pTexture != NULL, "Invalid texture for sprite");

    CCRect rect = CCRectZero;
    rect.size = pTexture->getContentSize();

    return initWithTexture(pTexture, rect);
}

    这些代码都在告诉我们,如果m_fContentScaleFactor = 2,那代码会对Sprite的纹理进行缩放,让上面得到的数据是经过contentScaleFactor变换的,我们可以认为我们所用的实际资源大小是 原资源的1/m_fContentScaleFactor即可。

Cocos2d-x 3.0rc0集成Google AdMob SDK

1 Comment

话说Cocos2d-x 3.0上一周迫不及待地发布了正式版,本是一件值得庆幸的事情。但由于不可解决的技术问题,引擎无奈将Android平台的NativeActivity 实现重新回退到了Cocos2d-x 2.2.x版本的实现方案。由于之前已经将 GameDemo移植到了Cocos2d-x 3.0rc0版,直观感受到了NativeActivity方案带来的游戏操作体验上的提升(触屏事件的响应),因此心里总是“挂念”引擎的 NativeActivity方案。按照Cocos2d-x官方人士的说法,只要NDK的问题修复,NativeActivity还是未来引擎在 Android平台上的第一选择。我的理解,将来Cocos2d-x的某个版本中还会恢复NativeActivity的实现方案。

上次只是将GameDemo的核心逻辑移植到了Cocos2d-x 3.0rc0上,但一些外部SDK集成尚未做完,这两天闲遐时开始着手研究如何做,而首先要移植的就是Google AdMob SDK。关于Cocos2d-x 3.0rc0集成Google AdMob SDK,网上已经有了技术方案原型,最初应该是一个外国开发者提出的方案,后来被CocoaChina cocos2d-x社区版主翻译成了中文教程,这里基本上也是参考的这个方案,只是做了局部细化和进一步说明,希望能帮助大家进一步解惑。

一、功能说明

GameDemo游戏分为三个场景:WelcomeScene、GameScene以及EndScene。集成AdMob SDK的功能要求如下:

    – 当游戏进入到WelcomeScene时,AdMob SDK完成初始化,发出Ad Request,当收到Ad的时候才会在屏幕上方显示带有Ad的窗口;
    – 当点击Start进入到GameScene时,隐藏Ad窗口,以不干扰玩家的游戏操作为优先;
    – 当游戏Over进入到EndScene的时候,恢复显示Ad窗口;
    – 当玩家点击“Retry”回到GameScene时,隐藏Ad窗口。

二、方案原理

这个方案就是通过android.widget.PopupWindow实现广告窗口悬浮在当前窗口之上。按照Android官方Doc中的说 明,PopupWindow用于实现一个弹出框,它可以使用任意布局的View作为其内容,这个弹出框是悬浮在当前activity之上的。

至于PopupWindow为何能显示在当前Activity之上,可以查看PopupWindow的源码,大致思路就是PopupWindow通过 new时传入的context(当前Activity)获得了当前WindowManager,并将AdView作为子View添加到该窗口中。这里简要列出一些关键源码,大家可粗略理解一下:

    public PopupWindow(Context context, AttributeSet attrs,
         int defStyleAttr, int defStyleRes) {
        mContext = context;
        mWindowManager = (WindowManager)context
               .getSystemService(Context.WINDOW_SERVICE);
        …. …
    }

    public void setContentView(View contentView) {
        if (isShowing()) {
            return;
        }

        mContentView = contentView;

        if (mContext == null && mContentView != null) {
            mContext = mContentView.getContext();
        }

        if (mWindowManager == null && mContentView != null) {
            mWindowManager = (WindowManager) mContext
                  .getSystemService(Context.WINDOW_SERVICE);
        }
    }

    public void showAtLocation(View parent, int gravity, int x, int y) {
        showAtLocation(parent.getWindowToken(), gravity, x, y);
    }

    public void showAtLocation(IBinder token, int gravity, int x, int y) {
        if (isShowing() || mContentView == null) {
            return;
        }

        unregisterForScrollChanged();

        mIsShowing = true;
        mIsDropdown = false;

        WindowManager.LayoutParams p = createPopupLayout(token);
        p.windowAnimations = computeAnimationResource();
      
        preparePopup(p);
        if (gravity == Gravity.NO_GRAVITY) {
            gravity = Gravity.TOP | Gravity.START;
        }
        p.gravity = gravity;
        p.x = x;
        p.y = y;
        if (mHeightMode < 0) p.height = mLastHeight = mHeightMode;
        if (mWidthMode < 0) p.width = mLastWidth = mWidthMode;
        invokePopup(p);
    }
   
    private void invokePopup(WindowManager.LayoutParams p) {
        if (mContext != null) {
            p.packageName = mContext.getPackageName();
        }
        mPopupView.setFitsSystemWindows(mLayoutInsetDecor);
        setLayoutDirectionFromAnchor();
        mWindowManager.addView(mPopupView, p);
    }

UI线程负责更新窗口,因此popupWindow的创建与操作都应该通过runOnUiThread传递给UI线程执行。

游戏逻辑的C++层代码通过Jni控制PopupWindow的Show和dismiss。别忘了C++层的代码是在渲染线程执行的哦,这也是为何要用handler和runOnUIThread的原因。

在Cocos2d-x 2.2.2版本集成AdMob时,AdMob只是在收到ad后才显示出广告Banner,但是在cocos2d-x 3.0rc0中,当广告加载未成功时,android.widget.PopupWindow会显示一个小黑框,特难看,因此我们需要自己来控制。

三、集成步骤

【AdMob准备】

  到Google AdMob注册一个帐号,如果你有Google帐号,那直接开通AdMob(需填写更加详细的信息)。
  下载Google AdMob SDK,之前一直用GoogleAdMobAdsSdk-6.4.1.jar,这里还以这个版本为例。但Google官方已经不推荐这个版本了,你可以下 载Google Play Services版本的Mobile Ads API,但代码与这里会稍有不同。将下载的jar包放到GameDemo/proj.android/libs中。
 
【修改AndroidManifest.xml】

  在<Application>标签里添加:

  <activity
     android:name="com.google.ads.AdActivity"
     android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|uiMode|screenSize|smallestScreenSize" />
  <meta-data
     android:name="ADMOB_PUBLISHER_ID"
     android:value="YOUR_PUBLISHER_ID_VALUE" />

  权限方面至少包含如下两个:
   <uses-permission android:name="android.permission.INTERNET"/>
   <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>

【Java层代码集成】

我们只需要修改GameDemoActivity.java这个文件。

import android.app.NativeActivity;
import android.os.Bundle;
import android.util.Log;

import android.os.Handler;
import android.os.Message;
import android.view.Gravity;
import android.view.View;
import android.view.Gravity;
import android.view.ViewGroup.LayoutParams;
import android.view.ViewGroup.MarginLayoutParams;
import android.view.WindowManager;
import android.widget.LinearLayout;

import android.widget.PopupWindow;
import com.google.ads.AdRequest;
import com.google.ads.AdSize;
import com.google.ads.AdView;
import com.google.ads.AdListener;
import com.google.ads.Ad;

public class GameDemoActivity extends NativeActivity{
    private static GameDemoActivity context;

    private AdView adView;
    private PopupWindow popUpWindow;
    private LinearLayout popupWindowLayout;
    private LinearLayout mainActivityLayout;
    private boolean hasAdReceived;

    private static Handler adHandler = new Handler() {
        public void handleMessage(Message msg) {
            if (!context.hasAdReceived)
                return;

            switch (msg.what) {
                case 0:
                    if (View.VISIBLE ==
                        context.adView.getVisibility()) {
                        context.adView.setVisibility(View.GONE);
                        context.popUpWindow.dismiss();
                    }
                    break;
                case 1:
                    if (View.VISIBLE !=
                        context.adView.getVisibility()) {
                        context.adView.setVisibility(View.VISIBLE);
                        context.popUpWindow.showAtLocation(
                           context.mainActivityLayout, Gravity.TOP, 0, 0);
                    }
                    break;
            }
        }
    };

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
        context = this;
        adView = new AdView(this, AdSize.BANNER, "a1533d6de900e31");
        adView.setAdListener(new AdmobListener());
        //初始时,广告View隐藏起来
        adView.setVisibility(View.GONE);
    }

    public static void setupAds(){
        context.initAdPopupWindow();
    }

    public void initAdPopupWindow() {
        if (adView != null) {
            context.runOnUiThread(new Runnable() {
                 @Override
                 public void run() {
                     MarginLayoutParams params = new MarginLayoutParams(
                                     LayoutParams.WRAP_CONTENT,
                                     LayoutParams.WRAP_CONTENT);
                     params.setMargins(0, 0, 0, 0);

                     popupWindowLayout = new LinearLayout(context);
                     popupWindowLayout.setPadding(-5, -5, -5, -5);
                     popupWindowLayout.setOrientation(LinearLayout.VERTICAL);
                     popupWindowLayout.addView(adView, params);

                     popUpWindow = new PopupWindow(context);
                     popUpWindow.setWidth(320);
                     popUpWindow.setHeight(50);
                     popUpWindow.setWindowLayoutMode(LayoutParams.WRAP_CONTENT,
                                               LayoutParams.WRAP_CONTENT);
                     popUpWindow.setClippingEnabled(false);
                     popUpWindow.setContentView(popupWindowLayout);

                     mainActivityLayout = new LinearLayout(context);
                     context.setContentView(mainActivityLayout, params);

                     context.hasAdReceived = false;

                     AdRequest adRequest = new AdRequest();
                     //测试Admob时使用测试Device,发布版需要去掉这行代码
                     adRequest.addTestDevice("CCE4220B2509A406B515E7C9A205AEE1");
                     context.adView.loadAd(adRequest);

                     popUpWindow.update();
                }
            });
        }
    }

    //GoogleAdMobAdsSdk-6.4.1版本中的AdListener是interface,因此
    //我们需要override所有接口,但只有onReceiveAd是我们关心的。
    private class AdmobListener implements AdListener {
        @Override
        public void onReceiveAd(Ad ad) {
            //只有第一次成功接收Ad后,我们后续才显示广告窗口,否则
            //popupWindow会显示为一个小黑框,特难看。
            Log.d("GameDemo", "onReceiveAd");
            if (!hasAdReceived){
                hasAdReceived = true;               
            }
        }

        @Override
        public void onFailedToReceiveAd(Ad ad,
                            AdRequest.ErrorCode error) {
            Log.d("GameDemo",
              "failed to receive ad (" + error+ ")");
        }

        @Override
        public void onPresentScreen(Ad ad) {
            Log.d("GameDemo", "onPresentScreen");
        }

        @Override
        public void onDismissScreen(Ad ad) {
            Log.d("GameDemo", "onDismisScreen");
        }

        @Override
        public void onLeaveApplication(Ad ad) {
            Log.d("GameDemo", "onLeaveApp");
        }
    }

    public static void setAdVisible(boolean b) {
       Message msg = new Message();
        if (b) {
            msg.what = 1;
        } else {
            msg.what = 0;
        }
        adHandler.sendMessage(msg);
    }
}

【C++层代码集成】

在AppDelegate.cpp中添加setupAds方法:

void AppDelegate::setupAds()
{
#if (CC_TARGET_PLATFORM == CC_PLATFORM_ANDROID)
    JniMethodInfo t;
    if (JniHelper::getStaticMethodInfo(t, "com/tonybai/game/GameDemoActivity", "setupAds", "()V")) {
        t.env->CallStaticVoidMethod(t.classID, t.methodID);
        if (t.env->ExceptionOccurred()) {
            t.env->ExceptionDescribe();
            t.env->ExceptionClear();
            return;
        }
        t.env->DeleteLocalRef(t.classID);
    }
#endif
}

在WelcomeScene的init方法中调用setupAds:

bool WelcomeScene::init()
{
    bool bRet = false;
    do {
       … …
       AppDelegate *app = (AppDelegate*)Application::getInstance();
       app->setupAds();

       bRet=true;
    } while(0);

    return bRet;
}

在点击Start按钮时,隐藏Ad:

void WelcomeScene::menuStartCallback(Ref* pSender)
{
    AppDelegate *app = (AppDelegate*)Application::getInstance();

    app->setAdVisible(false);
    GameScene *gameScene = GameScene::create();
    Director::getInstance()->replaceScene(gameScene);
}

集成步骤到此就结束了,编译ok就可以部署到模拟器上运行测试一番了。

四、使用Cocos2d-x 3.0rc0引擎遇到的两个问题

【问题1】用cocos2d-x 3.0rc0的cocos编译高版本引擎生成的工程遇到的问题

 cocos2d-x 3.0rc0生成的proj.android/build-cfg.json与高版本(rc1~正式版)  cocos生成的工程的build-cfg.json稍有不同,用cocos2d-x 3.0rc0版cocos编译高版本cocos生成的project,会提示build_android组件无法找到“copy_to_assets”。 因此需要手动修改proj.android/build-cfg.json,将其中的:

    "copy_resources": [
        {
            "from": "../Resources",
            "to": ""
        }
    ],

改为:

   "copy_to_assets" :[
            "../Resources/"
            ],

【问题2】 W/dalvikvm(1194): dvmFindClassByName rejecting 'org/cocos2dx/lib/Cocos2dxHelper'

使用cocos2d-x 3.0rc0编译的项目,总是出现如下问题,但似乎这个错误还不影响程序的运行:

04-29 09:44:34.968: D/JniHelper(1194): JniHelper::setJavaVM(0xb8835730), pthread_self() = B897B518
04-29 09:44:34.968: W/dalvikvm(1194): dvmFindClassByName rejecting 'org/cocos2dx/lib/Cocos2dxHelper'

Google了一下,这个问题很多童鞋都遇到了,但给出的solution却不甚令我满意,于是我就求甚解的细致挖掘了一下,终于找到了一个让我还算满意 的答案。我们分析一下这两条日志,rejecting那条日志总是伴随setJavaVM后面出现的。可引擎什么时候setJavaVM了呢?显然是在 native_app_glue创建的子进程中,引擎需要attachCurrentThread,获得JniEnv时才做的。于是我们打开cocos2d-x-3.0rc0/cocos/2d/platform/android/nativeactivity.cpp一看究竟。

在nativeactivity.cpp中有两处"org/cocos2dx/lib/Cocos2dxHelper",我们先看engine_handle_cmd中的那处:

static void engine_handle_cmd(struct android_app* app, int32_t cmd)
{
        …. ….
        case APP_CMD_INIT_WINDOW:
            LOG_RENDER_DEBUG("android_main : APP_CMD_INIT_WINDOW");
            // The window is being shown, get it ready.
            if (engine->app->window != NULL) {
                cocos_dimensions d = engine_init_display(engine);
                if ((d.w > 0) &&
                    (d.h > 0)) {
                    cocos2d::JniHelper::setJavaVM(app->activity->vm);
                    cocos2d::JniHelper::setClassLoaderFrom(app->activity->clazz);

                    // call Cocos2dxHelper.init()
                    cocos2d::JniMethodInfo ccxhelperInit;
                    if (!cocos2d::JniHelper::getStaticMethodInfo(ccxhelperInit,
                                                                 "org/cocos2dx/lib/Cocos2dxHelper",
                                                                 "init",
                                                                 "(Landroid/app/Activity;)V")) {
                        LOGI("cocos2d::JniHelper::getStaticMethodInfo(ccxhelperInit) FAILED");
                    }
                    ccxhelperInit.env->CallStaticVoidMethod(ccxhelperInit.classID,
                                                            ccxhelperInit.methodID,
                                                            app->activity->clazz);

                    cocos_init(d, app);
                }
                engine->animating = 1;
                engine_draw_frame(engine);
            }
            break;
    … …
}

初步可以断定,那两条日志就是执行到这里输出的,但为何dvmFindClassByName方法会rejecting “org/cocos2dx/lib/Cocos2dxHelper”这个类名呢?我们还得翻看dalvik虚拟机源码:

 /dalvik/vm/native/InternalNative.cpp

/*
 * Find a class by name, initializing it if requested.
 */
ClassObject* dvmFindClassByName(StringObject* nameObj, Object* loader,
        bool doInit)
{
    ClassObject* clazz = NULL;
    char* name = NULL;
    char* descriptor = NULL;
    if (nameObj == NULL) {
        dvmThrowNullPointerException("name == null");
        goto bail;
    }
    name = dvmCreateCstrFromString(nameObj);
    /*
     * We need to validate and convert the name (from x.y.z to x/y/z). This
     * is especially handy for array types, since we want to avoid
     * auto-generating bogus array classes.
     */
    if (!dexIsValidClassName(name, true)) {
        ALOGW("dvmFindClassByName rejecting '%s'", name);
        dvmThrowClassNotFoundException(name);
        goto bail;
    }
    … …
}

我们的确找到了输出rejecting日志的地方,通过注释我们可以看到这个方法是用来验证名字对象,并将x.y.z形式的名字转换成x/y/z的。但引 擎中传入的就是“x/y/z”格式,因此这个方法输出了错误日志。我尝试将上面engine_handle_cmd中的"org/cocos2dx/lib/Cocos2dxHelper"改成"org.cocos2dx.lib.Cocos2dxHelper",错误日志果然消失了。

不过目前仍不能解释一点:为何在其他位置,比如在前面的AppDelegate::setupAds中,使用x/y/z格式的jni调用参数却没有输出错误日志!难道两个位置dalvik虚拟机使用的是不同的名字对象查找和加载方法?这个目前尚无定论。

Cocos2d-x 3.0多线程异步资源加载

7 Comments

Cocos2d-x2.x版本到上周刚刚才发布的Cocos2d-x 3.0 Final版,其引擎驱动核心依旧是一个单线程的“死循环”,一旦某一帧遇到了“大活儿”,比如Size很大的纹理资源加载或网络IO或大量计算,画面将 不可避免出现卡顿以及响应迟缓的现象。从古老的Win32 GUI编程那时起,Guru们就告诉我们:别阻塞主线程(UI线程),让Worker线程去做那些“大活儿”吧。

手机游戏,即便是休闲类的小游戏,往往也涉及大量纹理资源、音视频资源、文件读写以及网络通信,处理的稍有不甚就会出现画面卡顿,交互不畅的情况。虽然引 擎在某些方面提供了一些支持,但有些时候还是自己祭出Worker线程这个法宝比较灵活,下面就以Cocos2d-x 3.0 Final版游戏初始化为例(针对Android平台),说说如何进行多线程资源加载。

我们经常看到一些手机游戏,启动之后首先会显示一个带有公司Logo的闪屏画面(Flash Screen),然后才会进入一个游戏Welcome场景,点击“开始”才正式进入游戏主场景。而这里Flash Screen的展示环节往往在后台还会做另外一件事,那就是加载游戏的图片资源,音乐音效资源以及配置数据读取,这算是一个“障眼法”吧,目的就是提高用 户体验,这样后续场景渲染以及场景切换直接使用已经cache到内存中的数据即可,无需再行加载。

一、为游戏添加FlashScene

在游戏App初始化时,我们首先创建FlashScene,让游戏尽快显示FlashScene画面:

// AppDelegate.cpp
bool AppDelegate::applicationDidFinishLaunching() {
    … …
    FlashScene* scene = FlashScene::create();
    pDirector->runWithScene(scene);

    return true;
}

在FlashScene init时,我们创建一个Resource Load Thread,我们用一个ResourceLoadIndicator作为渲染线程与Worker线程之间交互的媒介。

//FlashScene.h

struct ResourceLoadIndicator {
    pthread_mutex_t mutex;
    bool load_done;
    void *context;
};

class FlashScene : public Scene
{
public:
    FlashScene(void);
    ~FlashScene(void);

    virtual bool init();

    CREATE_FUNC(FlashScene);
    bool getResourceLoadIndicator();
    void setResourceLoadIndicator(bool flag);

private:
     void updateScene(float dt);

private:
     ResourceLoadIndicator rli;
};

// FlashScene.cpp
bool FlashScene::init()
{
    bool bRet = false;
    do {
        CC_BREAK_IF(!CCScene::init());
        Size winSize = Director::getInstance()->getWinSize();

        //FlashScene自己的资源只能同步加载了
        Sprite *bg = Sprite::create("FlashSceenBg.png");
        CC_BREAK_IF(!bg);
        bg->setPosition(ccp(winSize.width/2, winSize.height/2));
        this->addChild(bg, 0);

        this->schedule(schedule_selector(FlashScene::updateScene)
                       , 0.01f);

        //start the resource loading thread
        rli.load_done = false;
        rli.context = (void*)this;
        pthread_mutex_init(&rli.mutex, NULL);
        pthread_attr_t attr;
        pthread_attr_init(&attr);
        pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);
        pthread_t thread;
        pthread_create(&thread, &attr,
                    resource_load_thread_entry, &rli);

        bRet=true;
    } while(0);

    return bRet;
}

static void* resource_load_thread_entry(void* param)
{
    AppDelegate *app = (AppDelegate*)Application::getInstance();
    ResourceLoadIndicator *rli = (ResourceLoadIndicator*)param;
    FlashScene *scene = (FlashScene*)rli->context;

    //load music effect resource
    … …

    //init from config files
    … …

    //load images data in worker thread
    SpriteFrameCache::getInstance()->addSpriteFramesWithFile(
                                       "All-Sprites.plist");
    … …

    //set loading done
    scene->setResourceLoadIndicator(true);
    return NULL;
}

bool FlashScene::getResourceLoadIndicator()
{
    bool flag;
    pthread_mutex_lock(&rli.mutex);
    flag = rli.load_done;
    pthread_mutex_unlock(&rli.mutex);
    return flag;
}

void FlashScene::setResourceLoadIndicator(bool flag)
{
    pthread_mutex_lock(&rli.mutex);
    rli.load_done = flag;
    pthread_mutex_unlock(&rli.mutex);
    return;
}

我们在定时器回调函数中对indicator标志位进行检查,当发现加载ok后,切换到接下来的游戏开始场景:

void FlashScene::updateScene(float dt)
{
    if (getResourceLoadIndicator()) {
        Director::getInstance()->replaceScene(
                              WelcomeScene::create());
    }
}

到此,FlashScene的初始设计和实现完成了。Run一下试试吧。

二、崩溃

GenyMotion的4.4.2模拟器上,游戏运行的结果并没有如我期望,FlashScreen显现后游戏就异常崩溃退出了。

通过monitor分析游戏的运行日志,我们看到了如下一些异常日志:

threadid=24: thread exiting, not yet detached (count=0)
threadid=24: thread exiting, not yet detached (count=1)
threadid=24: native thread exited without detaching

很是奇怪啊,我们在创建线程时,明明设置了 PTHREAD_CREATE_DETACHED属性了啊:

pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);

怎么还会出现这个问题,而且居然有三条日志。翻看了一下引擎内核的代码TextureCache::addImageAsync,在线程创建以及线程主函数中也没有发现什么特别的设置。为何内核可以创建线程,我自己创建就会崩溃呢。Debug多个来回,问题似乎聚焦在resource_load_thread_entry中执行的任务。在我的代码里,我利用SimpleAudioEngine加载了音效资源、利用UserDefault读取了一些持久化的数据,把这两个任务去掉,游戏就会进入到下一个环节而不会崩溃。

SimpleAudioEngine和UserDefault能有什么共同点呢?Jni调用。没错,这两个接口底层要适配多个平台,而对于Android 平台,他们都用到了Jni提供的接口去调用Java中的方法。而Jni对多线程是有约束的。Android开发者官网上有这么一段话:

All threads are Linux threads, scheduled by the kernel. They're usually started from managed code (using Thread.start), but they can also be created elsewhere and then attached to the JavaVM. For example, a thread started with pthread_create can be attached with the JNI AttachCurrentThread or AttachCurrentThreadAsDaemon functions. Until a thread is attached, it has no JNIEnv, and cannot make JNI calls.

由此看来pthread_create创建的新线程默认情况下是不能进行Jni接口调用的,除非Attach到Vm,获得一个JniEnv对象,并且在线 程exit前要Detach Vm。好,我们来尝试一下,Cocos2d-x引擎提供了一些JniHelper方法,可以方便进行Jni相关操作。

#if (CC_TARGET_PLATFORM == CC_PLATFORM_ANDROID)
#include "platform/android/jni/JniHelper.h"
#include <jni.h>
#endif

static void* resource_load_thread_entry(void* param)
{
    … …

    JavaVM *vm;
    JNIEnv *env;
    vm = JniHelper::getJavaVM();

    JavaVMAttachArgs thread_args;

    thread_args.name = "Resource Load";
    thread_args.version = JNI_VERSION_1_4;
    thread_args.group = NULL;

    vm->AttachCurrentThread(&env, &thread_args);
    … …
    //Your Jni Calls
    … …

    vm->DetachCurrentThread();
    … …
    return NULL;
}

关于什么是JavaVM,什么是JniEnv,Android Developer官方文档中是这样描述的:

The JavaVM provides the "invocation interface" functions, which allow you to create and destroy a JavaVM. In theory you can have multiple JavaVMs per process, but Android only allows one.
The JNIEnv provides most of the JNI functions. Your native functions all receive a JNIEnv as the first argument.
The JNIEnv is used for thread-local storage. For this reason, you cannot share a JNIEnv between threads.

三、黑屏

上面的代码成功解决了线程崩溃的问题,但问题还没完,因为接下来我们又遇到了“黑屏”事件。所谓的“黑屏”,其实并不是全黑。但进入游戏 WelcomScene时,只有Scene中的LabelTTF实例能显示出来,其余Sprite都无法显示。显然肯定与我们在Worker线程加载纹理 资源有关了:

SpriteFrameCache::getInstance()->addSpriteFramesWithFile("All-Sprites.plist");

我们通过碎图压缩到一张大纹理的方式建立SpriteFrame,这是Cocos2d-x推荐的优化手段。但要想找到这个问题的根源,还得看monitor日志。我们的确发现了一些异常日志:

libEGL: call to OpenGL ES API with no current context (logged once per thread)

通过Google得知,只有Renderer Thread才能进行egl调用,因为egl的context是在Renderer Thread创建的,Worker Thread并没有EGL的context,在进行egl操作时,无法找到context,因此操作都是失败的,纹理也就无法显示出来。要解决这个问题就 得查看一下TextureCache::addImageAsync是如何做的了。

TextureCache::addImageAsync只是在worker线程进行了image数据的加载,而纹理对象Texture2D instance则是在addImageAsyncCallBack中创建的。也就是说纹理还是在Renderer线程中创建的,因此不会出现我们上面的 “黑屏”问题。模仿addImageAsync,我们来修改一下代码:

static void* resource_load_thread_entry(void* param)
{
    … …
    allSpritesImage = new Image();
    allSpritesImage->initWithImageFile("All-Sprites.png");
    … …
}

void FlashScene::updateScene(float dt)
{
    if (getResourceLoadIndicator()) {
        // construct texture with preloaded images
        Texture2D *allSpritesTexture = TextureCache::getInstance()->
                           addImage(allSpritesImage, "All-Sprites.png");
        allSpritesImage->release();
        SpriteFrameCache::getInstance()->addSpriteFramesWithFile(
                           "All-Sprites.plist", allSpritesTexture);
     
        Director::getInstance()->replaceScene(WelcomeScene::create());
    }
}

完成这一修改后,游戏画面就变得一切正常了,多线程资源加载机制正式生效。

Older Entries