开源项目Swift-2048学习、分析

开源项目Swift-2048学习

这篇博客写了什么?

刚开始使用swift编写ios程序,花了两三天的时间看了下《The Swift Programming Language》,看了就忘了o(╯□╰)o,于是干脆从一个项目入手,一边开发一边学习。github上面找了一个挺有名的开源项目[swift-2048][id],经过一天半的"刻苦"学习,终于略有小成。
[id]: https://github.com/austinzheng/swift-2048 "gitHub"

项目结构

除去xocde7自动生成的一些文件外,austinzheng(2048项目作者),一共就使用7个.swift文件完成了整个项目,让本人第一感觉这个项目挺简单的。接下来就简单的介绍一下每一个文件的大概用处。

  • GameModel

    全工程最庞大的一个文件,在models文件夹下,这个文件主要是算法的实现(移动合并算法),虽然说是model文件后缀,但是本人实在想不到这个文件和MVC中的model有什么关系。
  • AuxiliaryModels

    里面定义着本项目用到的所有的、用户自定义的结构体与枚举
  • AccessoryViews

    本文件里面定义着代表分数的view
  • GameboardView

    和文件名称一样,这个文件是游戏的主要面板也就是下面这个
  • TileView

    文件名也出卖了它,这个让用户看起来就是2048游戏中的那些可以移动的数字。
  • NumberTileGame

    游戏的主要控制器,几乎所有的逻辑均在这里处理
  • AppearanceProvider

    项目辅助功能,它决定着游戏中数字以及TileView的颜色

076D9327-8943-4CCD-986F-F5310B82D30F.png


代码分析

以文件为单位,对2048项目进行一个简单的分析。

TileView.swift

2048项目里面较为简单的一个文件。TileView也就是2048中可以移动的方块

  • value 属性

    这个属性代表着一个tileView上面所显示的分数的数值,并设置了一个属性观察器didSet,这个属性观察器在每一次value属性被设置新值的时候调用。目的是为了在每一次值改变的时候给TileView的背景颜色、文字颜色、以及TileView上显示的Label数值,这给3个属性重新赋值(2048游戏中,TIleVIew数值的不同,颜色是不一样的,2,4,8的颜色都不一样)

  • delegate 属性

    遵守AppearanceProviderProtocol协议的代理,主要用于更改颜色

  • numberLabel 属性

    TileView上每个数字都是一个Label

  • 构造方法

    无疑是对TileView本身进行一些初始化,包括本身的大小以及UILabel的创建之类

AccessoryViews.swift

前面项目结构以及分析了,这个文件主要负责获得分数的显示.这个文件也十分的简单,里面主要的类就是 ScoreView它遵守了ScoreViewProtocol协议。因为简单,所以不过多解释.

  • score 属性

    游戏总共的得分,同样也有一个didSet属性观察器,再每次score属性发生改变的时候,更新Label的显示

  • scoreChanged 方法
    在每一次分数改变的时候调用,用于跟新分数显示(个人感觉这个方法和协议写的有点多余)

AppearanceProvider.swift

一个辅助用的,主要用于TileView颜色的控制,简单不多解释.(没有这个文件提供的功能,项目一样可以跑,只是丑点)

GameboardView.swift

一个稍稍复杂的文件,代表游戏面板的view,也就是下面这个黑框框(十分明显的九宫格布局)。当然还实现了一些对TileView的移动、插入等操作。接下来只解释一些主要属性。

%E5%B1%8F%E5%B9%95%E5%BF%AB%E7%85%A7%202015-10-30%2010.19.37.png

  • tiles 属性
    一个Dictionary<NSIndexPath, TileView>类型的数据,其实就是OC里面的一个字典,只是key换成了NSIndexPath类型,存储的valueTileView。所以这个字典里面存储的是整个游戏中,所有的TIleView,至于使用NSIndexPath做为key的目的很明显,能够使用类似于(1,3)的方式便捷取出任意一个TileView。

  • provider 属性
    控制颜色用的。。仅仅在创建TIleView的时候作为参赛传入即可

  • xxxTime xxScale
    一系列以这种方式结尾的属性,都是用来控制动画用的。。直接忽视它们吧。。。

  • 构造方法
    构造方法除了对属性的一些初始化,最主要的任务就是创建上图的那个九宫格黑框框,调用的方法便是setupBackground

  • setupBackground 方法
    十分经典的九宫格布局的创建方法。。也没啥可说的,(啥?你不知道什么是九宫格布局?)
  • insertTile 方法
    这个就是创建TileView并且显示的方法,方法接收(pos: (Int, Int), value: Int)2个参数,pos一个元组类型的数据,表示TileView应该插入的位置,Value便是值咯。创建完后利用下面这句话添加进入tiles字典(这下知道为什么tiles类型会是<NSIndexPath, TileView>了吧)

    tiles[NSIndexPath(forRow: row, inSection: col)] = tile
  • moveOneTile 方法

    从名字就可以知道,这是移动一个tileView的方法,参数from、to、value的意义也十分明了。整个过程就是,首先利用from取出需要移动的TileView,然后根据to参数计算出目标位置的x,y。

      func moveOneTile(from: (Int, Int), to: (Int, Int), value: Int) { 
        ...
        //这里是计算目标位置的x,y
    finalFrame.origin.x = tilePadding + CGFloat(toCol)*(tileWidth + tilePadding)
    finalFrame.origin.y = tilePadding + CGFloat(toRow)*(tileWidth + tilePadding)

    // 将原来的TileView从tiles中剔除
    tiles.removeValueForKey(fromKey)
    // 将拥有新位置的tileView重新放入tiles字典
    tiles[toKey] = tile
    // 到这里,内部逻辑已经将tile平移,但是界面没有显示
    // Animate
    ...//一些动画效果,设置需要移动tile的frame和数值
  }
  • moveTwoTiles 方法

    功能几乎和moveOneTile类似,只是它的from有两个来源。为什么会有两个来源?想一想tileView的合并。两个TileView合并过程应该是这样的。

    |[][2][][2]| ==>向左滑动 |[2][2][][]| ==> |[4][][][]|

    所以是先移动,再进行合并!
    整个的逻辑是这样

    1 根据from取出2个需要移动的tile

    2 根据to参数计算出需要移动的位置(2个同时都是移动到那个位置,动画给人一种合并的效果)

    3 将2个tile从tiles字典中移除,并将其中一个(总是里to这个位置最近的那个)tiles添加进tiles字典

    4 在动画中修改两个tiles的frame(营造动画效果)

    5 将其中一个从GameboadView中移除,设置另外一个tileView新的value数值。

    到达这里,完成平移与合并操作。

NumberTileGame.swift

这个文件就是本项目最主要的一个视图控制器的实现。处理着绝大部分的逻辑。

  • model 属性

    这个属性稍后会在GameModel.swift中进行详细介绍,这里先有个初步了解,它处理游戏中平移与合并算法。2048游戏最重要也是最难的便是它的平移和移动算法的实现。

  • 其他属性

    其他的属性几乎都是前面介绍过的。

  • 构造方法

    构造方法中对几个属性进行了初始化,并调用setupSwipeControls方法添加了4个手势识别器。

  • setupGame 方法

    ViewDidLoad调用,用来对游戏界面进行初始化(创建显示分数的ScoreView和游戏面板GameboardView)。值得一提的便是其中的两个用于计算x,y的内嵌方法。xPositionToCenterViewyPositionForViewAtPosition。首先得明白,游戏中"大"的view就只有显示总分数的scoreView和游戏面板GameboardView。而这两个view的位置关系总是scoreView在上GameboardView在下,并且两个view都是居于屏幕最中央(水平且垂直居中)。自己可以换几个不同屏幕大小试一试。而让他们位置有自适应的能力的便就是xPositionToCenterViewyPositionForViewAtPosition两个内嵌方法

    func xPositionToCenterView(v: UIView) -> CGFloat {
        let viewWidth = v.bounds.size.width
        let tentativeX = 0.5*(vcWidth - viewWidth)
        return tentativeX >= 0 ? tentativeX : 0
    }        `  ``

这个方法还不算复杂,其目的是:计算出能使传入参数v这个view,在控制器中居中显示的x的值。

        func yPositionForViewAtPosition(order: Int, views: [UIView]) -> CGFloat {
        ...
    //所有控件高度之和(包括间距),views.map({ $0.bounds.size.height })将所有view的高度取出,然后通过.reduce对所有高度进行求和。
      let totalHeight = CGFloat(views.count - 1)*viewPadding + views.map({ $0.bounds.size.height }).reduce(verticalViewOffset, combine: { $0 + $1 })
      // 这便是计算出来的views整体的起点y
      let viewsTop = 0.5*(vcHeight - totalHeight) >= 0 ? 0.5*(vcHeight - totalHeight) : 0

      // 然后根据order,数值0,代表第一个view也就是最上面的;数值1就是第二个view(本项目一共只有2个view所以也就是最下面的view)计算出任意一个view的y值
      var acc: CGFloat = 0
      for i in 0..<order {
        acc += viewPadding + views[i].bounds.size.height
      }
      return viewsTop + acc
    }

yPositionForViewAtPosition就比较复杂了。前面已经说明这两个方法是为了让两个view居中显示。yPositionForViewAtPosition就是为了找到能让任意一个view垂直居中的y值。因为在垂直面上有多个view(这里是2个),所以单独凭借一个view是无法计算的,必须把所有的view都传进来,再根据所有view的高度和计算出scoreView应该距离顶部的位置或者GameboardView距离底部的位置。

F2D24870-B7CD-478E-B58A-C5118A1EA958.png

scoreVIewGameboardView创建完后,调用insertTileAtRandomLocation插入2个tileVIew结束。

  • followUp 方法

    在每一次成功移动tileView后调用,判定游戏是否结束,如果没有结束随机生成一个tileview

  • upCommand 方法

    手势识别器的监听方法,当上划操作时候调用。这里调用GameModel的queueMove方法,进行移动操作(稍后会有解释)。其他Command方法几乎一样

  • 一堆代理方法

    基本都是调用其他文件内实现的方法.

GameModel

终于来到2048核心所在!先来简单的看一下所包含的属性。

  • gameboard 属性

    它代表的是游戏的逻辑面板,为什么是逻辑面板?在前面已经有一个GameboardView这是一个实际的能人用户看到的游戏面板,空的就是黑黑的框,有数值的就能看到一个个2、4、8之类的数字。这些都是呈现给用户看的。而我们实际进行计算的是在gameboard这个逻辑面板中,定义如下.

    struct SquareGameboard<T> {
    
  let dimension : Int  // 面板大小
  var boardArray : [T] // 这里是存储TileObject类型的数组

  init(dimension d: Int, initialValue: T) {
    dimension = d
    boardArray = [T](count:d*d, repeatedValue:initialValue)
  }
    //下标脚本,这样能够快速访问到boardArray任意一个元素(gameboard[0][1])
  subscript(row: Int, col: Int) -> T {
    get {
      assert(row >= 0 && row < dimension)
      assert(col >= 0 && col < dimension)
      return boardArray[row*dimension + col]
    }
    set {
      assert(row >= 0 && row < dimension)
      assert(col >= 0 && col < dimension)
      boardArray[row*dimension + col] = newValue
    }
  }
    ...
}

附上gameboardGameboardView关系图一张

%E5%85%B3%E7%B3%BB%E5%9B%BE%202.png

  • queue 属性

    一个装MoveCommand枚举的数组,这个枚举的意思是:direction这次滑动是哪个方向,completion以及tileView移动完成后需要做些什么。这里定义成一个数组。然而实际这个数组长度是不可能超过1的。。

  • timer 属性

    一个定时器,目的是为了不让因为手指滑动过快而导致tileView过快的移动(实际也没有什么用。。因为你的手速是一般达不到那么快的)

  • queueMove 方法

    NumberTileGame.swift已经提及到,一旦用进行滑动操作,便会调用这个方法。首先先将操作放入quenen数组,然后在看有没有定时器在运行,没有就调用timerFired方法.在属性解释的时候已经说过quenen以及定时器的作用(其实并没什么作用)。

  • timerFired 方法

    进行一些无聊的判断后调用performMove准备进行移动!当然如果有发生移动,那么得重新启动定时器.

  • performMove 方法

    重点终于来了!一进来就看到一个庞大的闭包

        //闭包接收一个整数作为参数,并且返回一个[(int),(int)]装有元组的数组
        let coordinateGenerator: (Int) -> [(Int, Int)] = { (iteration: Int) -> [(Int, Int)] in
        var buffer = Array<(Int, Int)>(count:self.dimension, repeatedValue: (0, 0))
        for i in 0..<self.dimension {
            switch direction {
            case .Up: buffer[i] = (i, iteration)
            case .Down: buffer[i] = (self.dimension - i - 1, iteration)
            case .Left: buffer[i] = (iteration, i)
            case .Right: buffer[i] = (iteration, self.dimension - i - 1)
        }
      }
      return buffer
    }
    

这个方法目的是:根据滑动方向的不同,返回对应的滑动顺序。看不懂?没事,看下面的图解。

%E9%97%AD%E5%8C%85%E9%A1%BA%E5%BA%8F1.png

接着继续往下走,下面的代码就是把取出来准备进行移动计算的每一列(行),根据其(Int, Int)类型的数据,取出gameboard对应的每一项TIleObject

      // coords数组存放的顺序也就是移动的顺序
      let tiles = coords.map() { (c: (Int, Int)) -> TileObject in
        let (x, y) = c
        return self.gameboard[x, y]
      }

提供一个思考方式

map%E8%BD%AC%E5%8C%961.png

每当获得tiles这个需要的移动的逻辑tiles,便会开始执行合并操作--调用merge方法.这个方法返回bool类型,只有发生移动/合并操作才会返回true.读到这里,笔者推荐先去看看下文介绍的merge方法的实现再继续看下面的解释。

读到这里,笔者默认你已经读完下面解释merge三个步骤

任然接着算法第三步返回的例子,返回值我还记得是【SingleMoveOrder(1, 0,4,ture),SingleMoveOrder(2,1,2,false)】
接着对返回的数据(操作)进行处理,项目使用的是forin的循环。请看代码注释(先感叹一下,好巧妙的映射方式)

      for object in orders {
        switch object {
        case let MoveOrder.SingleMoveOrder(s, d, v, wasMerge):
          // 是不是已经忘记coords里面是什么了?同样在map映射解释那有写哦
          let (sx, sy) = coords[s]//针对我们的例子这里的值是[(1,3),1,2),1,1),1,0)]。  我们算出来都是2个SingleMoveOrder类型,这里分析合并情况即s=1,所以取出来的是(1,2)
          let (dx, dy) = coords[d] //这里取出来的是(1,3)
          if wasMerge {
            score += v    //这个是总得分,这里会触发属性观察器从而调用代理
          }
          gameboard[sx, sy] = TileObject.Empty  // 设置(跟新)逻辑面板状态
          gameboard[dx, dy] = TileObject.Tile(v)// 设置(跟新)逻辑面板状态
          //到这里,逻辑面板已经移动完毕,接下来就是改变UI了,所以调用下面的方法。这个方法在GameboardView.swift中实现.
          delegate.moveOneTile(coords[s], to: coords[d], value: v)
            
        case let MoveOrder.DoubleMoveOrder(s1, s2, d, v):
          // Perform a simultaneous two-tile move
          let (s1x, s1y) = coords[s1]
          let (s2x, s2y) = coords[s2]
          let (dx, dy) = coords[d]
          score += v
          gameboard[s1x, s1y] = TileObject.Empty
          gameboard[s2x, s2y] = TileObject.Empty
          gameboard[dx, dy] = TileObject.Tile(v)
          delegate.moveTwoTiles((coords[s1], coords[s2]), to: coords[d], value: v)
        }
      }
      

SingleMoveOrderDoubleMoveOrder处理上几乎一致,所以继续分析。到这里,整个项目几乎已经分析完。项目中最难理解的一个是合并算法,另外一个笔者便认为是项目作者设计的一种巧妙的映射方式,最后附上全部映射关系图一张.

%E5%85%A8%E6%98%A0%E5%B0%84%E5%9B%BE%202.png

  • merge 方法

    2048的作者已经将算法分为三步来实现,分别对应3个方法condensecollapseconvert,接下来对这些方法进行逐个解释

  • condense 方法

    在介绍详细算法之前,必须先知道,这个项目的合并算法是先移动再合并!
    而合并其实就是将2个需要合并的tile,一个从GameboardView中删除,另外一个则改变其数值大小,给人一种合并的假象。
    例如
    [2][ ][2][4]--->是先将下标为2的2移动到下标为0的2(重叠)
    哪怕是这样
    [2][2][4][8]--->也是先将标为1的2移动到下标为0的2(重叠)

    合并算法的第一步.

    目的:“去除”数组中的空的项(也就是移动),列如[2][][4][] ---> [2][4]

    var tokenBuffer = [ActionToken]()

ActionToken枚举里面定义了一共四种操作类型,condense只用到两种,后两种便先不仔细介绍.这个方法是为了移动。那怎么样判断是否需要移动呢?

需要移动的必须满足的条件

1:本身是非"空"的即tile不是.Empty类型

2:这个tile前面必须有"位置"能够移动,也就是说,排在这个tile移动顺序之前的tiles内最少有以一个是.Empty类型.

TileObject有两个类型的值.Empty代表空的。 .tile(value)代表这个是存在Tile的,满足条件1.

如果是[2][2][2][2]这种情况,tokenBuffer会老老实实的调用tokenBuffer.append(ActionToken.NoAction(source: idx, value: value))把这些全部添加进去。一旦出现了一个为.Empty类型的tile,这次switch会直接执行default,从而导致where tokenBuffer.count == idx这个条件永远为false!.这才会调用tokenBuffer.append(ActionToken.Move(source: idx, value: value))

这个方法执行完,返回tokenBuffer,此时tokenBuffer中装有是所有tile需要进行的操作要么是Move要么是NoAction。任然用这个例子:

%E5%B1%8F%E5%B9%95%E5%BF%AB%E7%85%A7%202015-10-30%2019.26.40.png

在我向右滑动上面那个面板,用第二行来举例。condense接收的group内容是
【Value(2),Value(2),Value(2),Empty】(不知道为什么?返回看上面map映射的解释那张图)在经过本condense计算后返回的tokenBuffer是【NoAtion(0,2),NoAtion(1,2),NoAtion(2,2)】

这里必须得对ActionToken进行一些解释,这个source参数是代表这个tile的的原位置不是它在tokenBuffer中的位置,一定得记住是tile的的原位置!tile的的原位置!tile的的原位置!

  • collapse

    合并算法第二步:合并相同数值的tile:[2][2][2][4] -> [4][2][4]

    根据代码进行分析,笔者添加了很多注释

    
    func collapse(group: [ActionToken]) -> [ActionToken] {

    var tokenBuffer = [ActionToken]()
    var skipNext = false //如果发生了合并的操作,那么下一个tile将不进行操作(已经被合并)
    for (idx, token) in group.enumerate() {
      if skipNext {
        //如果发生了合并的操作,那么下一个tile将不进行操作(已经被合并)
        skipNext = false
        continue
      }
      switch token {
        ...
      case let .NoAction(s, v)
        where (idx < group.count-1
          && v == group[idx+1].getValue()
          && GameModel.quiescentTileStillQuiescent(idx, outputLength: tokenBuffer.count, originalPosition: s)):
         //这种情况应用在tile需要合并但不需要移动的情况,这个方法在一个数组队列中最多调用一次,
        //即tile需要合并但不需要移动的情况只有一次,因为一旦需要合并,后面的tile都需要进行移动操作
        //哪怕是在第一步计算中是不需要移动的
        let next = group[idx+1]
        let nv = v + group[idx+1].getValue()
        skipNext = true
        tokenBuffer.append(ActionToken.SingleCombine(source: next.getSource(), value: nv))
      case let t where (idx < group.count-1 && t.getValue() == group[idx+1].getValue()):
//这种情况应用在tile需要移动且需要合并的情况,需要移动的状态包括第一步计算出来的与前面发生过合并导致的
        let next = group[idx+1]
        let nv = t.getValue() + group[idx+1].getValue()
        skipNext = true
        tokenBuffer.append(ActionToken.DoubleCombine(source: t.getSource(), second: next.getSource(), value: nv))
      case let .NoAction(s, v) where !GameModel.quiescentTileStillQuiescent(idx, outputLength: tokenBuffer.count, originalPosition: s):
        //这种情况应用于需要移动但不需要合并,而需要移动是因为前面进行过合并操作造成的
        tokenBuffer.append(ActionToken.Move(source: s, value: v))
      case let .NoAction(s, v):
        //不需要移动且不合并
        tokenBuffer.append(ActionToken.NoAction(source: s, value: v))
      case let .Move(s, v):
        // 仅仅需要移动
        tokenBuffer.append(ActionToken.Move(source: s, value: v))
      default:
        break
      }
    }
    return tokenBuffer
  }

总结一下,合并/移动的分类

1 tile需要合并但不需要移动的情况,这个种情况在一列/行tiles中最多存在一次。 因为因为一旦需要合并,后面的tile都需要进行移动操作。哪怕是在第一步计算中是不需 要移动的。列如[2][2][8][2]---->[4][ ][8][2]--->[4][8][2][],这种情况经 过算法第一步后全是NoAction,但是在算法第二步因为前2个[2]会发生合并,所以导 致第二个位置会空从而导致原本NoAction的[8][2]也要移动,所以一旦有发生合并,后 面的都必须进行移动.

2 tile需要移动且需要合并的情况,需要移动的状态包括第一步计算出来的与前面发生过 合并导致的。比如[2][2][4][4]-->[4][ ][4][4]--->[4][8][][]

3 需要移动但不需要合并,而需要移动是因为前面进行过合并操作造成的(参考1)

4 不需要移动且不合并:列如[2][4][8][16]

5 仅仅需要移动,比如[2][ ][4][8] -->[2][4][8]

最后解释一下为什么在第一种分类下是SingleCombine而第二种是 DoubleCombine。两个类型的不同就在于DoubleCombine多了一个second:参 数。至于为什么这样?不妨回想一下上面1情况与2情况的分别。没错,区别在与我说的 tile是否需要移动,郑重强调:只要发生合并操作,绝对是需要进行tile的移动 的!绝对需要移动,是不是感觉奇怪,明明前面说不需要移动。对,我说tile不需要移 动是指for循环中token代表的当前的那个tile,但是合并是两个tile的事情, 所以let next = group[idx+1]取出了下一个tile,而这个利用idx+1取出来 的tile是一定得需要移动的,而token代表的那个tile不一定需要移动,所以: SingleCombine是指只要移动一个tile的情况,而DoubleCombine是值2个需 要合并的tile均需要移动!(其实在分析condense(算法第一步)的时候已经强 调,算法的步骤是先移动再合并)。任然回到算法第一步最后那个例子
%E5%B1%8F%E5%B9%95%E5%BF%AB%E7%85%A7%202015-10-30%2019.26.40.png

经过第一步计算传入的group内容是【NoAtion(0,2),NoAtion(1,2),NoAtion(2,2)】
经过第二步计算出来的返回值tokenBuffer是【SingleCombine(1,4),Move(2,2)】

  • convert

    这是最后一步了,这里又多出了一个MoveOrder枚举,这个枚举把需要进行的操作再度 简化就分为SingleMoveOrderDoubleMoveOrder两种操作类别,十分显然 DoubleMoveOrder对应的是前面需要进行两个tile移动的且这两个需要合并的操作( 对应算法第二步分类中的2)。SingleMoveOrder是单一一个tile的移动操作。啥?你说少了一种合并情况?tile需要合并当不需要移动的操作被吃了?我就问了:算法第二步分类中的1是不是也需要移动一块被合并的tile(用let next = group[idx+1]取出的那块)?,这就对了,SingleMoveOrder有个参数wasMerge:代表的就是需不需要合并。所以SingleMoveOrder对应算法第二步中分类的1、3、5。至于4,人家都说了不移动不合并,就让人家好好原地待着。接着例子来,我们看看最后返回的是什么

    %E5%B1%8F%E5%B9%95%E5%BF%AB%E7%85%A7%202015-10-30%2019.26.40.png

    经过第二步计算传入的group是【SingleCombine(1,4),Move(2,2)】
    经过第三步计算出返回值moveBuffer是【SingleMoveOrder(1, 0,4,ture),SingleMoveOrder(2,1,2,false)】
    看到这里算法分析基本结束,可以返回去继续看performMove方法,看后续操作.

总结

到这里应该就告一段落了,虽然还有些代码没有分析,但那些是不太重要的东西了。整个项目可以说不难,比较适合初学者,关键是要理解作者设置的映射关系与合并算法,。想要彻底的了解这个2048,最好就是自己从头到尾从零开始写一个。先从搭建界面开始,一步一步慢慢的来。笔者2048项目花了1天办时间重写,而写这篇文章却花了将近三天。如果有时间。可能会继续写关于2048的博文,应该是一步一步的去记录实现2048的步骤。文章中有什么漏洞或者错误,欢迎指出,我们一起学习^_^



转载于:https://www.cnblogs.com/xiaolang-swift/p/4928065.html

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值