BT

如何利用碎片时间提升技术认知与能力? 点击获取答案

使用Swift和SceneKit开发一片圣诞树林

| 作者 Alex Blewitt 关注 4 他的粉丝 ,译者 刘君 关注 0 他的粉丝 发布于 2015年5月2日. 估计阅读时间: 22 分钟 | GMTC大前端的下一站,PWA、Web框架、Node等最新最热的大前端话题邀你一起共同探讨。

过去几年中,移动应用像风暴一样席卷世界,改变了我们的网上工作、娱乐方式。很多移动应用开发技术应运而生,而移动也开始得到开发过程重视。尽管移动已经看似无所不在,但未来才刚刚开始。新一代的移动设备,好比可穿戴设备,好比物联网的许许多多移动构件,就在我们面前。我们会发现:用于数据展示和命令接收的用户界面不断地推陈出新;越来越多的公司站在真正的移动浪潮之巅。这会在未来几年中影响软件的设计、开发、测试方式。

InfoQ的这篇文章是快速变革的移动技术系列的一部分。你可以在这里订阅该系列的新文章通知。

今年,随着Swift编程语言及其1.0版的发布,苹果允许向AppStore提交用Swift开发的iOS应用,为iOS编写应用从未如此简单。这篇文章展示了用Swift创建iOS应用的全过程,以及如何通过SceneKit展示3D图形。

本文中代码的Swift版本为1.0版,仅供学习使用。

软件需求

创建本文相关代码之前,先要在OSX 10.9或更高版本中安装Xcode 6.1。文中的代码可以直接在模拟器中运行,想要部署到硬件设备上的话,需要激活iOS开发者账号。

iOS新项目创建

创建新项目,可以通过Xcode启动时的欢迎对话框,或是“文件→新建→项目”菜单。

出现在Xcode新窗口菜单中的新对话框包含了多种iOS或OS X应用选项。有很多缺省模版类型,用以创建不同行为的示例代码项目。

选择“iOS→应用→游戏”可以从众多不同类型中选择一个来创建模版:

  • SceneKit
  • SpriteKit
  • OpenGL ES
  • Metal

SceneKit创建一个面向通用设备的名为MerrySwiftmas的Swift应用。(通用应用意味着该应用可以运行在iPad和iPhone/iPod上;如果应用只面向一种设备,可以在此处或接下来的info.plist中进行更改)。

运行项目

这样就根据模版创建了一个新项目。项目的用户界面可以被划分为四个独立区域:左边是导航区(可以通过快捷键⌘1-9进行切换);右上部是各种检视器(可以通过快捷键⌥⌘1-9进行切换);右下方是各个类库(可以通过快捷键^⌥⌘1-9进行切换)。

窗口中间是编辑区,该区域会显示导航区所选中的文件。选中最顶层项目时,编辑区会展示一系列通用信息(这些信息存储在info.plist文件中);单击选中其他文件时,编辑区会发生变化,双击文件时,则会弹出含有该文件的新窗口。

窗口顶部是运行按钮;运行意味着构建并启动缺省目标设备及应用;本例中,是一个加载应用的模拟器窗口:

Swift代码简介

飞船的加载、显示是靠GameViewController.swift文件中的viewDidLoad函数。

import UIKit
import SceneKit

class GameViewController: UIViewController {
  override func viewDidLoad() {
    super.viewDidLoad()
    let scene = SCNScene()
    ...
    let scnView = self.view as SCNView
    scnView.scene = scene
    scnView.autoenablesDefaultLighting = true
    scnView.allowsCameraControl = true
  }
  ...
}

正如其他C类型语言,块集成用花括号{},类成员访问用.,函数和构造器传参用括号()。参数可以是定位参数或命名参数,冒号隔开名称和值。

虽说Swift是静态编译的强类型应用,却不必声明变量类型。Swift会在编译时自动推导。Var用于声明变量,let用于声明常量。

关键字as用于类型转换,有了它,源于UIViewController超类、被声明为UIViewself.view就可以当SceneKit SCNView使用,进而对诸如scene或是allowsCameraControl这类SceneKit特有的字段进行访问。

替换宇宙飞船

视图控制器首次加载时,viewDidLoad()的函数体会被调用,函数体可以用上述代码替换。运行替换后的代码会得到不含其他信息的黑色屏幕。

场景图像通过结点结构进行展示,并以rootNode为顶层结点。任何结点都能添加子结点;结点的翻转变换会统一影响其所有子结点。

向图中添加柱体,可以像下面这样,创建一个含有SCNCylinderSCNNode并添加到图中:

...
scnView.allowsCameraControl = true

let rootNode = scene.rootNode
let cylinder = SCNCylinder(radius:1,height:3)
let tree = SCNNode(geometry: cylinder)
rootNode.addChildNode(tree)

如果现在运行应用,你会看到一个相当无趣的白块:

柱体看上去并不具备3D效果,这只是因为柱体尚未上色、场景尚未投灯光。这些都很容易搞定:可以像下面这样,将柱体的漫反射材质上色,场景投自动灯光:

...
rootNode.addChildNode(tree)

cylinder.firstMaterial?.diffuse.contents = UIColor.brownColor()
scnView.autoenablesDefaultLighting = true

现在再运行应用,柱体就会显示出着色后的的3D效果。因为场景允许摄像控制(采用上帝视角观察),所以可以通过手指或鼠标手势拖动场景:

下一步是在柱体之上添加锥体,方法与添加柱体类似。

...
scnView.autoenablesDefaultLighting = true

let cone = SCNNode(geometry: SCNCone(topRadius:0, bottomRadius:3, height:3))
cone.position.y = 3
cone.geometry?.firstMaterial?.diffuse.contents = UIColor.greenColor()
tree.addChildNode(cone)

为了把锥体放在柱体之上,初始位置上移了3个单元,但仍保持和柱体中心对称。?.是可选访问器:如果值为空,那么一切都不变;如果值非空,那么计算表达式的剩余部分。

现在应用的运行结果如下:

完成树

由于要在顶部添加更多锥体,锥体的创建代码会被重复多次。也可以用含有这部分代码的for循环替代。

...
scnView.autoenablesDefaultLighting = true

for i in 1...3 {
  let cone = SCNNode(geometry: SCNCone(topRadius:0, bottomRadius:3, height:3))
  cone.position.y = 2 * Float(i) + 1
  cone.geometry?.firstMaterial?.diffuse.contents = UIColor.greenColor()
  tree.addChildNode(cone)
}

for循环中,用1...3代表闭区间[1,2,3]。半闭合区间[1,2]可以用1..<3表示。通过堆叠锥体来生成树。

整型i可以通过Float强制转换成浮点型以便根据树根计算相对位置。

应用运行时,会显示一棵树:

添加礼物

SCNBox的话,可以把礼物放在树下。柱体(即树的底部)的位置在0,0,0及其之上,盒子的位置要略低一点:y=-1Xz方向随意。可以显式指定位置,但for循环所允许的位置只有(0,1),(1,0)以及(1,1)。

for i in 1...3 {
  let present = SCNNode(geometry: SCNBox(width: 1, height: 1, length: 1, chamferRadius: 0))
  present.geometry?.firstMaterial?.diffuse.contents = UIColor.blueColor()
  present.position.x = Float(i % 2) * 2
  present.position.z = Float(i / 2) * 2
  present.position.y = -1
  tree.addChildNode(present)
}

重构为类

把这些一股脑儿堆放在视图控制器加载的时候,并不是好的实践方案。这种情况下,代码复用和测试都会变得相对困难。实际上,创建树的相关代码可以单独成类。

用“文件→新建→文件”创建一个名为ChristmasTree的iOS Swift文件。文件创建时为空,需要导入SceneKit并声明一个名为ChristmasTree的类。

import SceneKit

class ChristmasTree {
}

想创建SCNNode子类的话,要把父类名称放在子类名称之后,并用冒号隔开。实现协议(接口)用的也是同样的语法。这样做时,还要添加一系列调用init的构造器:

class ChristmasTree: SCNNode {
  override init() {
    super.init()
  }
  required init(coder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }
}

Swift约定:对象要有一个主要(或缺省)初始化函数;在很多UIKit对象中,这意味着要遵循NSCoder协议,也就是说,要有一个编码器相关的init函数。通过把字段属性转换成序列化结构并还原,NSCoder能将Interface Builder中的对象持久化。如果你忘了写,Xcode会报错并帮你添上。这样做的实际效果就是,用Swift重写子类时,“需要”一个构造器,“重写”其他构造器。

现在类框架写好了,也该补充内容了。把创建树的代码从viewDidLoad方法移动到ChristmasTreeinit方法中来:

override init() {
  super.init()

  let cylinder = SCNCylinder(radius:1,height:3)
  let trunk = SCNNode(geometry: cylinder)
  cylinder.firstMaterial?.diffuse.contents = UIColor.brownColor()
addChildNode(trunk)

  for i in 1...3 {
    let cone = SCNNode(geometry: SCNCone(topRadius:0, bottomRadius:3, height:3))
    cone.position.y = 2 * Float(i) + 1
    cone.geometry?.firstMaterial?.diffuse.contents = UIColor.greenColor()
    addChildNode(cone)
  }

  for i in 1...3 {
    let present = SCNNode(geometry: SCNBox(width: 1, height: 1, length: 1, chamferRadius: 0))
    present.geometry?.firstMaterial?.diffuse.contents = UIColor.blueColor()
    present.position.x = Float(i % 2) * 2
    present.position.z = Float(i / 2) * 2
    present.position.y = -1
    addChildNode(present)
  }
}

这样就把树的添加工作留到视图控制器更新时再做:

override func viewDidLoad() {
  super.viewDidLoad()
  let scene = SCNScene()
  let scnView = self.view as SCNView
  scnView.scene = scene
  scnView.allowsCameraControl = true
  let rootNode = scene.rootNode
  let tree = ChristmasTree()
  rootNode.addChildNode(tree)
  scnView.autoenablesDefaultLighting = true
}

种植森林

可以拥有上千棵树,为何满足于只有一棵?现在树被抽象为一个类。种一百棵树很简单。用嵌套for循环的position属性安排每棵树的位置。在步长不为1的情况下,可以通过内置函数stride生成数字迭代器。

for x in stride(from:0, to:100, by:10) {
  for z in stride(from:0, to:100, by:10) {
    let tree = ChristmasTree()
    tree.position.x = Float(x)
    tree.position.z = Float(z)
    rootNode.addChildNode(tree)
  }
}

这样,一百棵树就种好了,间隔10个单位,分布在2D网格中。

所有树看上去都很相似,部分原因在于所有礼物的整齐排列。加入树的旋转后,会不再那么相似。旋转用弧度衡量:圆的弧度为2π,因此,想得到随机位置,可以选一个180以内的随机数,除以180,再乘以π:

tree.position.x = Float(x)
tree.position.z = Float(z)
tree.rotation.y = 1
tree.rotation.w = Float(M_PI) * Float(arc4random_uniform(180)) / Float(180)

现在森林看上去有那么一点不一样了:

添加颜色

目前所有礼物都是蓝色。如果你恰巧喜欢蓝色,就很不错,只是看上去会有一点单调。还有一种选择,你可以创建颜色序列,并随机选取。

尽管Swift支持structenum类型的static成员,但直到Swift1.1,尚不支持static类成员。(实际上,要是你试着向Swift类中添加static成员,编译器会抱怨你不该用static,而应该用class,可当你用了class,编译器又会表示尚不支持)

值得庆幸的是,类之外也能声明变量,这样声明的全局变量正好能存储构建好的颜色序列。通过随机系数就可以选取礼物的颜色。

let colors = [
  UIColor.blueColor(),
  UIColor.cyanColor(),
  UIColor.magentaColor(),
  UIColor.orangeColor(),
  UIColor.purpleColor(),
  UIColor.redColor(),
  UIColor.whiteColor(),
  UIColor.yellowColor(),
]

class ChristmasTree: SCNNode {
...
      let present = SCNNode(geometry: SCNBox(width: 1, height: 1, length: 1, chamferRadius: 0))
      present.geometry?.firstMaterial?.diffuse.contents = colors[random() % colors.count]

结论

Swift是一种很容易上手的语言,因为它有自己的REPL(swift,简单交互式编程环境,源于Lisp,译者注);只通过几行代码就可以用SceneKit创建交互图形应用。最后,祝你们圣诞快乐!

AlexGitHub空间可以找到该项目的源代码。

关于作者

二十年前,Dr Alex Blewitt第一次在NeXTstation上接触到Objective-C面向对象编程并一直使用至今。Swift的发布预示着OS X平台的未来,Alex为此写过一本书,Swift Essentials,该书定于下月出版发行。有空时,如果天气很好,Alex会从本地的Crafield机场试飞。

过去几年中,移动应用像风暴一样席卷世界,改变了我们的网上工作、娱乐方式。很多移动应用开发技术应运而生,而移动也开始得到开发过程重视。尽管移动已经看似无所不在,但未来才刚刚开始。新一代的移动设备,好比可穿戴设备,好比物联网的许许多多移动构件,就在我们面前。我们会发现:用于数据展示和命令接收的用户界面不断推陈出新;越来越多的公司站在真正的移动浪潮之巅。这会在未来几年中影响软件的设计、开发、测试方式。

InfoQ的这篇文章是快速变革的移动技术系列的一部分。你可以在这里订阅该系列的新文章通知。

查看英文原文:Merry Swiftmas from InfoQ


感谢夏雪对本文的审校。

给InfoQ中文站投稿或者参与内容翻译工作,请邮件至editors@cn.infoq.com。也欢迎大家通过新浪微博(@InfoQ@丁晓昀),微信(微信号:InfoQChina)关注我们,并与我们的编辑和其他读者朋友交流。

评价本文

专业度
风格

您好,朋友!

您需要 注册一个InfoQ账号 或者 才能进行评论。在您完成注册后还需要进行一些设置。

获得来自InfoQ的更多体验。

告诉我们您的想法

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我
社区评论

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我

讨论

登陆InfoQ,与你最关心的话题互动。


找回密码....

Follow

关注你最喜爱的话题和作者

快速浏览网站内你所感兴趣话题的精选内容。

Like

内容自由定制

选择想要阅读的主题和喜爱的作者定制自己的新闻源。

Notifications

获取更新

设置通知机制以获取内容更新对您而言是否重要

BT