原文:https://code.tutsplus.com/zh-...
原作:Akiel Khan
翻译:Susnm

图片描述

介绍

自从苹果跟swift一起推出了Xcode6以来,到现在的Xcode7.3.1,playground有了一个长足的发展。 随之而来的是,新功能和更好的稳定性,从而有可能进化成一个快速布局和结合概念的工具。

作为一个开发者,总有那么一瞬间,对某一个app有了灵感,你想去快速用代码创建布局来展示你idea的本质。 或者你想去验证你对UIKit代码片段的理解及行为。 如果你像我一样,宁可避免麻烦,创建Xcode项目,而不是处理无数个因素,比如设备类型,资源和设置等。 而这些决定都能够被推迟一直到你创建起你的构思确定核心代码之后再做决定。

在这个教程中,我创建了一个以卡片为基础的记忆累游戏,而这一切全都是在playground完成的。 这是一个大众的,众所周知的游戏,所以这里没有独创性。 游戏由8对相同的卡片组成(所以一共16张卡片)正面朝下放在4*4的网格中。

玩家需要翻转快速的显示两张卡片,然后会快速的翻转回去。 游戏的目的是让玩家尝试记住牌的位置,发现相同的一对,会被从游戏中删掉。 游戏在网格所有卡片被清除之后结束。

游戏以触摸为基础融合简单的视图动画。 你能学习到如何修改你的游戏,并生动的看到你修改的变化。

1) 开始

打开Xcode,然后从Xcode的File menu中选择New > Playground...。 给playground取一个名字,比如MemoryGameXCPTut,将Platform选项设置为iOS,保存playground。 在这个教程中我使用的是Xcode 7.3.1。

图片描述

找到你自己使用playground的方式
让我们花一些时间来熟悉我们的playground界面。 如果你已经熟悉了playgrounds,你可以跳过这个章节。

一个playground可以有多个pages,每一个都有自己的live view和自己的sources/resources文件夹。 在这个教程中我们不需要使用多个pages。 Playgrounds支持markup格式,允许你添加富文本到playground,并在多个playground的pages之间链接。

创建一个playground之后你看到的第一件东西就是playground的代码编辑区域。 这是你写代码的地方,你修改的效果会在live view上显示。 你可以使用Command-0快捷键来隐藏或显示项目导航栏。 在项目导航栏,你可以看到两个文件夹,Source和Resources。

Sources

在Source文件夹中,你能添加一些辅助代码到一个或多个swift文件中,比如自定义类,视图控制器和视图。 即使你在这里定义了大量的布局逻辑代码,当你的app动起来的时候,它也是在藏在背后辅助的。

将辅助代码放在Sources文件夹下的一个优势是这样它能在你每次编辑和保存文件后自动编译。 通过这个方法,你在playground里做修改时,能更快速的在live view中得到反馈。 回到playground,你能够访问在辅助代码中你暴露出来的以public修饰的属性和方法,从而来影响你app的行为。

Resources

你能添加额外的资源到Resources文件夹中,比如照片。

在这个教程中,你需要频繁的在我们创建在Sources文件夹中的swift文件和playground文件(技术上它是swift文件,但是我们不将使用文件名来引用它)之间切换。 在教程中我们也使用Assistant Editor,让它显示Timeline,在playground的代码旁边查看live view。 你在playground中做的任何改变都会立刻(好吧是几秒钟之内)反应到live ouptput中。 你也能跟live view触摸反馈,它是UI对象。 为了确保你能做这些,看一眼下面的插图。

图片描述

对应绿色的数字,我给出了下面的注解:

  1. 这个按钮是为了只显示主编辑区域,用来隐藏Assistant Editor。

  2. 这个按钮用来显示Assistant Editor。 Assistant Editor显示在主编辑区域的右边。 这个编译器会帮助我们显示相关的文件,比如主编辑器的文件的对应副本。

  3. 从左到右,有两个按钮各自用来切换项目导航栏和调试区域的显示。 在控制器我们可以输出一些东西的状态来检测。

  4. 主编译器上面的jump bar是用来导航到特殊的文件。 点击项目的名字两次带你回到playground。 或者你也可以使用项目导航栏回去playground。

有时候,当看playground的时候,你需要去确保Assistant Editor是显示的Timeline,而不是一些其他的文件。 下面的插图教你怎么做。 在Assistant Editor,选择Timeline,playground的副本,而不是Manual,它允许你在Assistant Editor中显示任意的文件。

图片描述

当你从Sources文件夹中编辑一个文件时,作为它的副本,Assistant Editor显示你代码的界面,它显示的是定义和布局没有实际的实现。 我在Source文件夹中修改一个文件时,更喜欢隐藏Assistant Editor,仅仅在playground中显示Assistant Editor用来看动视图。

去实现playground的特殊的能力,你需要导入 XCPlayground这个module。

import XCPlayground

你要给XCPlayground对象的currentPageliveView属性设置一个遵循XCPlaygroundLiveViewable协议的对象。 它可以是一个自定义的类或者是一个UIViewUIViewController实例。

添加文件到Sources/Resources文件夹

我在这个教程中添加了一些需要用到的images。 下载images,解压后添加这些Images到项目导航中的Resources文件夹下的Images文件夹。

确保只是拖images到Resources文件夹下,而不是Resources/Images文件夹下。

删除playground中的代码。 右键点击Sources文件夹,从菜单栏中先择New File。 设置文件的名字为Game.swift。

图片描述

2) 写Helper类和方法

添加下面的代码到Game.swift。 确保在每次添加了代码后你保存了。

import UIKit
import XCPlayground
import GameplayKit // (1)
 
public extension UIImage { // (2)
    public convenience init?(color: UIColor, size: CGSize = CGSize(width: 1, height: 1)) {
        let rect = CGRect(origin: .zero, size: size)
        UIGraphicsBeginImageContextWithOptions(rect.size, false, 0.0)
        color.setFill()
        UIRectFill(rect)
        let image = UIGraphicsGetImageFromCurrentImageContext()
        UIGraphicsEndImageContext()
         
        guard let cgImage = image.CGImage else { return nil }
        self.init(CGImage: cgImage)
    }
}
 
let cardWidth = CGFloat(120) // (3)
let cardHeight = CGFloat(141)
 
public class Card: UIImageView { // (4)
    public let x: Int
    public let y: Int
    public init(image: UIImage?, x: Int, y: Int) {
        self.x = x
        self.y = y
        super.init(image: image)
        self.backgroundColor = .grayColor()
        self.layer.cornerRadius = 10.0
        self.userInteractionEnabled = true
    }
     
    required public init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

我添加了一些数字标注用来解释一些节点实现的:

  1. 除了UIKitXCPlayground,我们也添加了GamePlayKit。 这个框架包括了一个便捷的方法来帮助我们执行一个随机打乱一个数组元素的方法。

  2. UIKit方法的帮助下,通过这个UIImage的扩展,我们可以快速的gray颜色的任意大小的images。 我们将使用这个作为playing card的背景图片。

  3. 常量cardHeightcartWidth表示card image的尺寸,然后以这个尺寸为基础计算其他的尺寸。

  4. Card类,继承自UIImageView,表示一张卡片。 即使我们设置了Card类的一些属性,我们创建这个类的目的是帮助我们在游戏中识别游戏卡片的子视图。 卡片也有它们的属性xy来记住它们在grid中的位置。

3) 控制器

添加下列代码到Game.swift,就在之前代码的后面:

public class GameController: UIViewController {
     
    // (1): public variables so we can manipulate them in the playground
    public var padding = CGFloat(20)/* {
        didSet {
            resetGrid()
        }
    } */
     
    public var backImage: UIImage = UIImage(
        color: .redColor(),
        size: CGSize(width: cardWidth, height: cardHeight))!
     
    // (2): computed properties
    var viewWidth: CGFloat {
        get {
            return 4 * cardWidth + 5 * padding
        }
    }
     
    var viewHeight: CGFloat {
        get {
            return 4 * cardHeight + 5 * padding
        }
    }
     
    var shuffledNumbers = [Int]() // stores shuffled card numbers
     
    // var firstCard: Card? // uncomment later
    
   
    public init() {
        super.init(nibName: nil, bundle: nil)
        preferredContentSize = CGSize(width: viewWidth, height: viewHeight)
        shuffle()
        setupGrid()
        // uncomment later:
        // let tap = UITapGestureRecognizer(target: self, action: #selector(GameController.handleTap(_:)))
        // view.addGestureRecognizer(tap)
     
         
    }
     
    required public init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
     
    public override func loadView() {
        view = UIView()
        view.backgroundColor = .blueColor()
        view.frame = CGRect(x: 0, y: 0, width: viewWidth, height: viewHeight)
    }
     
    // (3): Using GameplayKit API to generate a shuffling of the array [1, 1, 2, 2, ..., 8, 8]
    func shuffle() {
        let numbers = (1...8).flatMap{[$0, $0]}
        shuffledNumbers =
            GKRandomSource.sharedRandom().arrayByShufflingObjectsInArray(numbers) as! [Int]
         
    }
     
    // (4): Convert from card position on grid to index in the shuffled card numbers array
    func cardNumberAt(x: Int, _ y: Int) -> Int {
        assert(0 <= x && x < 4 && 0 <= y && y < 4)
         
        return shuffledNumbers[4 * x + y]
    }
    // (5): Position of card's center in superview
    func centerOfCardAt(x: Int, _ y: Int) -> CGPoint {
        assert(0 <= x && x < 4 && 0 <= y && y < 4)
        let (w, h) = (cardWidth + padding, cardHeight + padding)
        return CGPoint(
            x: CGFloat(x) * w + w/2 + padding/2,
            y: CGFloat(y) * h + h/2 + padding/2)
         
    }
     
    // (6): setup the subviews
    func setupGrid() {
        for i in 0..<4 {
            for j in 0..<4 {
                let n = cardNumberAt(i, j)
                let card = Card(image: UIImage(named: String(n)), x: i, y: j)
                card.tag = n
                card.center = centerOfCardAt(i, j)
                view.addSubview(card)
            }
        }
    }
     
    // (7): reset grid
/*
    func resetGrid() {
        view.frame = CGRect(x: 0, y: 0, width: viewWidth, height: viewHeight)
        for v in view.subviews {
            if let card = v as? Card {
                card.center = centerOfCardAt(card.x, card.y)
            }
        }
         
    }
*/   
    override public func viewDidAppear(animated: Bool) {
        for v in view.subviews {
            if let card = v as? Card {     // (8): failable casting
                UIView.transitionWithView(
                    card,
                    duration: 1.0,
                    options: .TransitionFlipFromLeft,
                    animations: {
                        card.image =  self.backImage
                    }, completion: nil)
            }
        }
    }
}
  1. 两个属性,paddingbackImage,是被定义为public,为了之后我们能在playground中访问到。 它们在gride上显示空白的card和各自的card背后显示image。 注意两个属性都已经给了初始值,一个20的padding间距值和红色的card image边框。 你现在线忽略那些代码上的注释了。

  2. 我们通过计算属性确定宽度和高度。 理解viewWidth计算,记住,每一行有四张卡片,我们需要去给每张卡片之间设置间距。 相同的思路计算viewHeight

  3. 代码(1...8).flatmap{[$0, $0]}是一个便捷的方法产生数组[1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8]。 如果你不熟悉函数式编程,你可以写for循环生成数组。 使用框架GamePlayKit的方法,我们可以洗牌数组中的数字。 这些数字对应八对卡片。 每一个数字对于卡片图片的名字(举个例子,shuffledArray中的值1对应1.png)。

  4. 我们写了一个方法,将4x4网格上的卡片位置转化为shuffledNumbers数组中的下标。 因素4反应了算数计算中每排的4张卡片。

  5. 我们也有一个方法以卡片的尺寸和间隔计算出卡片的位置(center属性)。

  6. setupGrid()方法在控制器的初始化方法中被调用。 这是4x4的Card网格布局。 以shuffledNumbers数组为基础给每一个卡片分配了唯一标示,储存这个值给tag属性,它是继承子card的基础类UIView。 游戏的原理是,我们通过比较两个卡片的tag判断是否匹配。 虽然这个造型方案非常简陋,但是对于面前的需要足够了。

  7. 这段注销的代码片段能在padding改变的时候重新定位cards。 记住我们定义padding为public,因为只有这样我们才能够在playground中访问。

  8. 代码viewDidAppear(_:)会在控制器的view变成可见之后立即执行。 我们遍历view的subViews,如果subview是Card类的实例(通过as?可失败操作符),在if的内部定义过度的执行。 在这里我们将改变image在cards中的显示,从每张卡片定义的漫画图片转换为普通的backImage。 这个过度伴随着从左向右翻转的动画,给cards以物理的翻转表现。 如果你不熟悉UIView是怎么工作的,这啃呢个看上去有一点奇怪。 即使我们单独的在循环中给card提阿佳动画,这些动画是同时过度喝执行的,这就是我们需要的卡片反转效果。

重新打开playground,用下面的代码代替编辑器中的所有的text:

import XCPlayground
import UIKit
 
let gc = GameController()
XCPlaygroundPage.currentPage.liveView = gc

确保timeline是可见的。 控制器的view会显示4x4的卡片网格,且会旋转给我们看卡片背后的图片。 现在,我不能对view做更多的事,因为我们没有实现任何跟交互有关的代码。 现在我们将开始定义了。

图片描述

4) 在playground上修改属性

现在,让我们改变卡片背面,从红色的方块改为图片(就是Resources文件夹下的 b.png)。 添加下面的代码到playground的底部。

gc.backImage = UIImage(named: "b")!

一两秒以后,我们可以看到卡片的编码从红色改为了手指的图片。

图片描述

现在,我们尝试修改padding属性,它在Game.swift中分配了一个默认的值20。 作为结果,卡片之间的间距应该增加。 添加下面的代码到playground的底部:

gc.padding = 75

等到动视图刷新,看到。。。没有事情发生变化。

5) 先讲点题外话

在继续之前,我们需要记住实体,比如控制器,它们分配了views,有一个复杂的生命周期。 我们将更关注后者,就是views。 创建和更新一个控制器的view是一个多阶段的过程。 在view的生命周期的特殊位置,通知是UIVIewController的事件,通知它,什么发生了。 更重要的,程序员能够通过插入代码去直接干预这个通知的进程。

loadView()viewDidAppear(_:)两个方法我们用来干预view的生命周期。 这些观点超出了我们的讨论范围,但是问题的关键是,代码在playground中,playground的liveView作为控制器的任务是执行viewWillAppear(_:)viewDidAppear(_:)。 你能在playground中修改一些属性来验证,或者在两个方法中添加print语句来显示这些属性的值。

padding的值改变没有预料到的视图效果,这个时候,view和subviews已经布局好了。 记住,无论什么时候你改变代码,playground都会从头开始运行。 这种情况不只是特别的对playground。 即使你开放在模拟器上或者物理设备中运行,通常你也要多次添加额外代码来确保属性值的改变确实对view的表现和内容有效。

你可能问为什么我们要改变backImage属性,但是看到的结果并没有任何特别的。 显然backImage属性是第一次viewDidAppear(_:)的时候使用的,来确定新值。

6) 监听属性和动作

我们处理这种情况的方法是去监听padding属性的改变,重置view和subViews。 幸运的是,这个使用swift中的属性监视器功能是很方便的。 打开Game.swift中关闭的代码段resetGrid()方法。

// (7): reset grid
func resetGrid() {
    view.frame = CGRect(x: 0, y: 0, width: viewWidth, height: viewHeight)
    for v in view.subviews {
        if let card = v as? Card {
            card.center = centerOfCardAt(card.x, card.y)
        }
    }
     
}

这个方法通过新值viewWidthviewHeight来计算每个Card对象和views的frame的位置。 使用刚修改的padding值为基础重新计算那些属性计算。

使用didSet监听器来修改padding的代码,这个名字说明,无论你什么时候给padding设置值都会被唤起这个监听器:

// (1): public variables so we can manipulate them in the playground
public var padding = CGFloat(20) {
    didSet {
        resetGrid()
    }
}

resetGrid()方法会重新刷新来回应这个新间距。 你可以在playground中验证。

图片描述

这里看上去我们很容易的修复了这个事情。 实际中,当我们第一次觉得想去跟padding属性交互的时候,我们必须回去,改变Game.swift中的代码。 举个例子,我不得不去从Card中剥离出单独的center计算方法centerOfCardAt(_:_:),以便无论你什么时候,都能简便独立的重新计算卡片的位置来布局。

viewWidthviewHeight作为计算属性也是有帮助的。 当重写一些代码的时候,你应该明白尽量在设计之前有过权衡,通过一些事先思考和经验,会减少代码的重写。

7) 游戏逻辑和触摸交互

现在是时候去执行游戏的逻辑,打开触摸交互。 取消GameController类的中定义的属性firstCard的注释:

var firstCard: Card?

游戏的原理是需要用到两个卡片,一个接一个翻开。 这个firstCard属性用来保持跟踪玩家执行翻牌的第一张翻牌或者没有。

添加下面的方法到GameController类的最底部,在最终的花括号之前:

func handleTap(gr: UITapGestureRecognizer) {
    let v = view.hitTest(gr.locationInView(view), withEvent: nil)!
    if let card = v as? Card {
        UIView.transitionWithView(
            card, duration: 0.5,
            options: .TransitionFlipFromLeft,
            animations: {card.image = UIImage(named: String(card.tag))}) { // trailing completion handler:
                _ in
                card.userInteractionEnabled = false
                if let pCard = self.firstCard {
                    if pCard.tag == card.tag {
                        UIView.animateWithDuration(
                            0.5,
                            animations: {card.alpha = 0.0},
                            completion: {_ in card.removeFromSuperview()})
                        UIView.animateWithDuration(
                            0.5,
                            animations: {pCard.alpha = 0.0},
                            completion: {_ in pCard.removeFromSuperview()})
                    } else {
                        UIView.transitionWithView(
                            card,
                            duration: 0.5,
                            options: .TransitionFlipFromLeft,
                            animations: {card.image = self.backImage})
                        { _ in card.userInteractionEnabled = true }
                        UIView.transitionWithView(
                            pCard,
                            duration: 0.5,
                            options: .TransitionFlipFromLeft,
                            animations: {pCard.image = self.backImage})
                        { _ in pCard.userInteractionEnabled = true }
                    }
                    self.firstCard = nil
                } else {
                    self.firstCard = card
                }
        }
    }
}

这是一个很长的方法。 这事因为要获取所有必要的触摸事件,游戏的逻辑联系动画在一个方法中。 让我们看看这个方法是怎么工作的:

  • 首先,有一个验证来确保时间上触摸的是Card实例。 这个as?跟我们之前使用的一样。

  • 如果玩家触摸的是Card实例,我们使用之前执行类似的动画翻转卡片。 仅仅新的一部分是我们使用了闭包来处理,他会在动画执行完毕后被调用,然后使用card的属性userInteractionEnabled临时关闭card的交互。 这是防止玩家翻转相同的卡片。 注意_ in结构在方法中被使用了多次。 这只是说我们忽略完成闭包捕获的参数Bool

  • 我们以firstCard是否被分配了nil的可选值绑定(swift的类似if let的结构)为基础来执行代码。

  • 如果firstCard不是nil,那么这是玩家单独翻的第二张卡片。 我们现在需要比较卡片的牌面和前面的一个是否匹配(通过比较值tag)。 如果相同,我们使用动画让卡片消失(通过设置卡片的alpha为0)。 我们也会从view上移除那些卡片。 如果tag是不想等的,意思就是卡片没有匹配,我们简单的将他们翻转回去,设置它们的userInteractionEnabledtrue,为了玩家可以再次选中他们。

  • 根据当前的firstCard的值,我们设置这个为nil,或者显示card。 这就是我们两个成功触摸的代码逻辑。

最后,取消对下面两行在GameCotroller的初始化中的注释,添加一个tap手势给view。 当tap手势发现一个tap的时候,方法handleTap()会被调用:

let tap = UITapGestureRecognizer(target: self, action: #selector(GameController.handleTap(_:)))
view.addGestureRecognizer(tap)

回到playground的timeline玩一下这个记忆游戏。 比之前分配的padding减少了不少感觉好多了。

方法handleTap(_:)里的代码是我第一次写的时候的版本。 一个反对的想法产生了,作为一个单个方法,它太长了。 或者说这个代码不足够面对对象,卡片的翻转逻辑和动画应该分离到Card类中。 当那些想法产生的时候,记住,快速布局是我们这个教程的目的。

一旦我们做了一些工作,我决定将来想要去追逐想法,我们无疑将需要考虑代码的重构。 换句话说,首先要让它工作,然后才是让它快速的、优雅的、完美的。。。

8) playground中的触摸事件

当现在教程的主要部分结束了,有趣的一部分是,我想去给你显示我会直接在playground中写触摸事件的代码。 我首先在GameController中添加一个方法,允许我们快速的撇一眼卡片的牌。 添加下面的代码到gameController类,就在方法handleTap(_:)的下面:

public func quickPeek() {
    for v in view.subviews {
        if let card = v as? Card {
            card.userInteractionEnabled = false
            UIView.transitionWithView(card, duration: 1.0, options: .TransitionFlipFromLeft, animations: {card.image =  UIImage(named: String(card.tag))}) {
                _ in
                UIView.transitionWithView(card, duration: 1.0, options: .TransitionFlipFromLeft, animations: {card.image =  self.backImage}) {
                    _ in
                    card.userInteractionEnabled = true
                }
 
            }
        }
    }
}

我们想要在playground内实现注销和激活这个“quick peek”的能力的功能。 一个方法就是去创建一个public的BoolGameController的类中,所以能在playground中设置。 当人,我们将不得不在GameController中写一个手势控制器,通过不同的手势激活,将唤醒quickPeek()

另一个方法是直接在playground中写手势控制器的代码。 这样做的优点是我们能够自定义的合并一些额外的代码调用quickPeek()。 这就是我接下来要做的。 添加下面的代码到playground的下面:

class LPGR {
    static var counter = 0
    @objc static func longPressed(lp: UILongPressGestureRecognizer) {
        if lp.state == .Began {
            gc.quickPeek()
            counter += 1
            print("You peeked \(counter) time(s).")
        }
    }
}
 
let longPress = UILongPressGestureRecognizer(target: LPGR.self, action: #selector(LPGR.longPressed))
longPress.minimumPressDuration = 2.0
gc.view.addGestureRecognizer(longPress)

为了激活quick peek的功能,我将使用一个长按的手势,玩家在屏幕上按住它们一会儿。 我们使用两秒作为触发条件。

为了控制这个手势,我创建了一个类,LPRG(长按手势识别器的缩写),还有一个static变量属性,counter,用来记录我们看了多少次,和一个static的方法longPressed(_:)来控制手势。

通过使用static修饰符,我们可以避免创建LPGR的实例,因为被static修饰的实体是LPGR类型的class,而不是特殊的实例。

除此之外,该方法没有特别的游戏。 反而又一个复杂的理由,我们需要去用关键词@objc修饰方法来让编译器不报错。 注意,现在使用LPGR.self来指引对象类型。 还要注意,在这个手势控制器中,我们要检查手势的state.Begin。 这是因为长按手势是过程的,只要玩家保持它们的手指在屏幕上手势识别器就会一直执行。 我们希望每个手指按压代码执行一次,当手势第一次被识别的时候我嘛执行代码。

counter是代码自定义增加的,不是作为功能被GameController类所提供的。 你可以在最下面的控制台看print(_:)方法(在peek几次之后)的输出。

图片描述

结论

但愿这个教程示范了一个有趣的在Xcode的playground中快速交互布局的例子。 使用playground除了我之前提到的理由外,你可以用来构成其他那些有用的情况。 举个例子:

  • 使用示范布局功能给你的委托人看,让他们有所选择,制造更加积极的自定义反馈,而不需要追究代码的详细内容。

  • 模拟开发,比如你的物理,学生可以玩一些属性值,从而观察模拟器是什么反应。 事实上,苹果公司放出了一个令人印象深刻的playground,展示了它们的互动和UIDynamicsAPI的物理引擎。 我强烈推荐你去看一下这个

当在使用playground的时候,以示范操作和教授为目的,你将可能通过使用playground的markup作为富文本和导航的能力而更佳开阔。

Xcode团队提交的改善的playground作为IDE的新版本放出了。 最大的消息就是Xcode8,目前是beta版本,将有一个新功能playgrounds for iPad。 但是,显然,playground不可能完全代替Xcode IDE,当开发结束的时候,需要到真实的设备上测试功能app。 最终它只是作为一个工具被使用,但是这是一个很有用的工具。


关于Envato艺云台

图片描述

Envato艺云台是数据资产和创造性人才汇聚的全球领先市场平台。全球数百万人都选择通过我们的市场平台、工作室和课程来购买文件、选聘自由职业者,或者学习创建网站、制作视频、应用、制图等所需的技能。我们的子网站包括Envato艺云台Tuts+ 网络,全球最大的H5、PS、插图、代码和摄影教程资源库,以及Envato艺云台市场,其中的900多万类数字资产均通过以下七大平台进行销售 - CodeCanyon、ThemeForest、GraphicRiver、VideoHive、PhotoDune、AudioJungle和3DOcean。


TriviumChina
81 声望2 粉丝

Envato艺云台是数据资产和创造性人才汇聚的全球领先市场平台。全球数百万人都选择通过我们的市场平台、工作室和课程来购买文件、选聘自由职业者,或者学习创建网站、制作视频、应用、制图等所需的技能。我们的子...