zoukankan      html  css  js  c++  java
  • Alamofire网络库进阶教程

    18.jpg

    本章节由CocoaChina翻译组成员星夜暮晨(博客)翻译自raywenderlich:Intermediate Alamofire Tutorial,敬请勘误。

    欢迎回到我们的 Alamofire 网络库使用教程,本文是此教程的第二部分,同时也是最后一个部分。

    教程的第一部分中,我们学习了 Alamofire 的一些基本用法,比如说发送 GET 请求、传递参数、创建请求路由以及创建自定义响应序列化方法。在学习的过程中,我们也生成了一个很赞的名为 Photomania 的图片库应用。在本教程的第二部分,您将会增加以下功能:

    • 照片查看器

    • 查看评论以及其他信息的功能

    • 下载照片功能,附带有一个圆列进度条

    • 优化网络访问以及图片缓存

    • 下拉刷新操作

    让我们开始吧

    您可以使用您在教程第一部分所完成的项目来开始本章教程。但是如果您跳过了第一部分的教程或者对自己的项目没有信心的话,那么您也可以使用我们提供的标准项目。

    提示:

    如果您跳过了第一部分的教程,那么请不要忘记您首先应当从 500px.com 网站上获取消费者密钥,然后在Five100px.swift中用其替换必要的部分。关于如何获取该密钥,以及在何处使用它,都在本教程的第一部分:Alamofire 网络库基础教程中有详细说明。

    生成并运行起始项目,以确定我们应用运行正常。图片预览功能能够正常工作,但是单击图片并不会将其以全屏打开。这就是我们所要解决的问题!

    创建图片查看器

    说句老实话,范型可以说是包括 Swift 在内的高级编程语言中最强大的特性之一。一般情况下,在我们这个项目中最好使用范型这个功能。

    打开Five100px.swift,然后在文件顶部,即import Alamofire语句下方添加以下代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    @objc public protocol ResponseObjectSerializable {
      init(response: NSHTTPURLResponse, representation: AnyObject)
    }
    extension Alamofire.Request {
      public func responseObject(completionHandler: (NSURLRequest, NSHTTPURLResponse?, T?, NSError?) -> Void) -> Self {
        let serializer: Serializer = { (request, response, data) in
          let JSONSerializer = Request.JSONResponseSerializer(options: .AllowFragments)
          let (JSON: AnyObject?, serializationError) = JSONSerializer(request, response, data)
          if response != nil && JSON != nil {
            return (T(response: response!, representation: JSON!), nil)
          else {
            return (nil, serializationError)
          }
        }
        return response(serializer: serializer, completionHandler: { (request, response, object, error) in
          completionHandler(request, response, object as? T, error)
        })
      }
    }

    在上述的代码中,我们再一次给 Alamofire 创建了一个扩展,添加了新的响应序列化方法。这次,我们添加了.responseObject()函数。作为一个通用函数,它能够序列化所有符合ResponseObjectSerializable协议的数据对象。

    这意味着,如果我们定义一个含有init(response:representation:)初始化方法的新类,那么 Alamofire 就能够自行从服务器返回该类型的对象。这时候,我们已经将序列化逻辑封装进了自定义类的内部。哈哈,是不是一个很赞的面向对象设计?

    44.jpg

    图片查看器使用的是PhotoInfo类,这个类遵守了ResponseObjectSerializable协议,并实现了所需的方法。不过您仍需要让这个类正式遵守ResponseObjectSerializable协议。

    打开Five100px.swift,并且修改PhotoInfo类的声明来让其明确遵守ResponseObjectSerializable协议,如下所示:

    1
    class PhotoInfo: NSObject, ResponseObjectSerializable {

    提示:

    虽然毋需详细了解representation参数在PhotoInfo对象中是如何序列化的,但是感兴趣的读者可以去浏览required init(response:representation:)方法来了解其工作原理。

    打开PhotoViewerViewController.swift,注意不是PhotoBrowserCollectionViewController.swift,然后在文件顶部加入一个必要的导入声明:

    1
    import Alamofire

    接着,在viewDidLoad()方法内的底部加入以下代码:

    1
    loadPhoto()

    您会得到一个找不到loadPhoto()的错误,但是不必担心,我们接下来就要实现这个函数。

    仍然是在同一个文件当中,在setupView()方法前加入以下代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    func loadPhoto() {
      Alamofire.request(Five100px.Router.PhotoInfo(self.photoID, .Large)).validate().responseObject() {
        (_, _, photoInfo: PhotoInfo?, error) in
        if error == nil {
          self.photoInfo = photoInfo
          dispatch_async(dispatch_get_main_queue()) {
            self.addButtomBar()
            self.title = photoInfo!.name
          }
          Alamofire.request(.GET, photoInfo!.url).validate().responseImage() {
            (_, _, image, error) in
            if error == nil && image != nil {
              self.imageView.image = image
              self.imageView.frame = self.centerFrameFromImage(image)
              self.spinner.stopAnimating()
              self.centerScrollViewContents()
            }
          }
        }
      }
    }

    这时,我们在其他 Alamofire 请求的完成处理方法中发出了 Alamofire 请求。第一个请求接收到了一个 JSON 响应数据,然后它使用我们新建的通用响应序列化方法,在JSON 数据之外创建了一个PhotoInfo实例。

    (_, _, photoInfo: PhotoInfo?, error) in是响应序列化方法的参数。刚开始的两个下划线"_"意味着我们忽略了前两个参数,我们无需明确地将它们明确命名为request和response。

    第三个参数明确被声明为了我们的PhotoInfo实例,因此通用序列化方法将自行初始化,并返回该类型的一个对象,其包含有图片 URL。第二个 Alamofire 请求使用您先前创建过的图片序列化方法,将NSData转化为UIImage,以便之后我们在图片视图中显示它。

    注意:

    我们不在这里使用路由,因为我们已经有了图片的绝对 URL 地址,我们无需自行构造 URL。

    在请求响应对象之前调用的.validate()函数是另一个易用的 Alamofire 特性。将其与请求和响应链接,以确认响应的状态码在默认可接受的范围(200到299)内。如果认证失败,响应处理方法将出现一个相关错误,您可以在完成处理方法中处理这个错误。

    即使没有发生错误,完成处理方法仍然还是会被调用。第四个参数error是NSError的一个实例,它的值让我们能够在自定义方法中响应这个错误。

    生成并运行您的应用,单击其中一副图片后您应当看到它将覆盖全屏幕,如下所示:

    QQ截图20141203100624.jpg

    好的!图片查看器正常工作了,双击该图片可以放大图片。

    当类型安全的通用响应序列化方法初始化PhotoInfo的时候,您不仅仅只是设置了id和url属性,还有一些属性您并没有看见。

    单击应用左下方的Menu按钮,然后您就可以看到该照片的详细信息:

    QQ截图20141203100729.jpg

    单击屏幕上的任意位置就可以关闭照片详细信息。

    如果您对 500px.com很熟悉的话,您就知道用户往往会在网站上给极佳的照片留下很多评论信息。现在既然我们的图片查看器正常工作了,那么现在我们就要开始搭建评论查看器了。

    为显示注释创建集合序列化方法

    对于有评论的图片来说,图片查看器会显示一个包含评论数的评论按钮。单击这个评论按钮会弹出一个评论列表。

    打开Five100px.swift,然后在import Alamofire声明下添加以下代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    @objc public protocol ResponseCollectionSerializable {
      class func collection(#response: NSHTTPURLResponse, representation: AnyObject) -> [Self]
    }
    extension Alamofire.Request {
      public func responseCollection(completionHandler: (NSURLRequest, NSHTTPURLResponse?, [T]?, NSError?) -> Void) -> Self {
        let serializer: Serializer = { (request, response, data) in
          let JSONSerializer = Request.JSONResponseSerializer(options: .AllowFragments)
          let (JSON: AnyObject?, serializationError) = JSONSerializer(request, response, data)
          if response != nil && JSON != nil {
            return (T.collection(response: response!, representation: JSON!), nil)
          else {
            return (nil, serializationError)
          }
        }
        return response(serializer: serializer, completionHandler: { (request, response, object, error) in
          completionHandler(request, response, object as? [T], error)
        })
      }
    }

    这段代码看起来很眼熟,它和我们之前创建的通用响应序列化方法相似。

    唯一的不同点是,这个协议定义了返回集合的一个类方法(而不是单个元素)。完成处理方法将集合作为其第三个参数,即[T],接着调用类型上的collection而不是调用初始化方法。

    仍然是在同一个文件当中,将整个Comment类替换为以下形式:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    final class Comment: ResponseCollectionSerializable {
      class func collection(#response: NSHTTPURLResponse, representation: AnyObject) -> [Comment] {
        var comments = [Comment]()
        for comment in representation.valueForKeyPath("comments") as [NSDictionary] {
          comments.append(Comment(JSON: comment))
        }
        return comments
      }
      let userFullname: String
      let userPictureURL: String
      let commentBody: String
      init(JSON: AnyObject) {
        userFullname = JSON.valueForKeyPath("user.fullname") as String
        userPictureURL = JSON.valueForKeyPath("user.userpic_url") as String
        commentBody = JSON.valueForKeyPath("body") as String
      }
    }

    这段代码让Comment遵守ResponseCollectionSerializable协议,因此它将和上面的响应序列化方法协同工作。

    现在您需要做的就是使用它。打开PhotoCommentsViewController.swift然后在文件顶部加入以下必要的导入声明:

    1
    import Alamofire

    现在在viewDidLoad()方法中的底部加入以下代码:

    1
    2
    3
    4
    5
    6
    7
    Alamofire.request(Five100px.Router.Comments(photoID, 1)).validate().responseCollection() {
      (_, _, comments: [Comment]?, error) in
      if error == nil {
        self.comments = comments
        self.tableView.reloadData()
      }
    }

    这段代码使用了您新建的响应序列化方法来解序列化位于Comment集合中响应的NSData,然后将它们保存在属性当中,最后在表视图中重新加载它们。

    接下来,向tableView(_:cellForRowAtIndexPath)中添加以下代码(在return cell语句上面):

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    cell.userFullnameLabel.text = comments![indexPath.row].userFullname
    cell.commentLabel.text = comments![indexPath.row].commentBody
    cell.userImageView.image = nil
    let imageURL = comments![indexPath.row].userPictureURL
    Alamofire.request(.GET, imageURL).validate().responseImage() {
      (request, _, image, error) in
      if error == nil {
        if request.URLString.isEqual(imageURL) {
          cell.userImageView.image = image
        }
      }
    }

    这段代码在表视图单元格中显示了评论信息,同样它还接连提交了第二个 Alamofire 请求来加载图片(这和我们在教程第一部分所做的相类似)。

    生成并运行您的应用,找到一个有评论的图片。您可以通过评论图标上的数字来了解该图片的评论数目。单击评论按钮,然后该图片的评论界面就会显示出来,如下所示:

    QQ截图20141203100911.jpg

    现在,您可能已经发现了几张您想下载的图片(也有可能是几百张哈),下一节我们将带领大家如何实现下载功能。

    显示下载进度

    图片查看器的底栏中间有一个动作按钮。它显示出一个UIActionSheet控件来让您选择是否下载照片,但是现在它还没有任何功能。到目前为止,我们所做的仅仅只是从 500px.com 加载到内存中。那么我们要如何下载并保存文件呢?

    打开PhotoViewerViewController.swift,将空函数downloadPhoto()用以下代码替换:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    func downloadPhoto() {
      // 1
      Alamofire.request(Five100px.Router.PhotoInfo(photoInfo!.id, .XLarge)).validate().responseJSON() {
        (_, _, JSON, error) in
        if error == nil {
          let jsonDictionary = (JSON as NSDictionary)
          let imageURL = jsonDictionary.valueForKeyPath("photo.image_url") as String
          // 2
          let destination = Alamofire.Request.suggestedDownloadDestination(directory: .DocumentDirectory, domain: .UserDomainMask)
          // 3
          Alamofire.download(.GET, imageURL, destination)
        }
      }
    }

    我们来依次解释一下这些代码的作用:

    我们首先请求一个新的PhotoInfo,此时请求的是XLarge大小。

    获取要保存文件的默认存储地址,我们将会将其存放在您应用的 Documents 目录的一个子目录中。该子目录的名字将和服务器建议的名字相同。destination是一个变相的闭包——虽然只是短短的一瞬间。

    Alamofire.download(_:_:_)方法和Alamofire.request(_:_)方法有很大的不同。Alamofire.download(_:_:_)方法不需要响应处理方法,也不需要响应序列化方法来对数据进行处理。因为它已经知道如何去处理这些数据了,就是把它们保存在硬盘上!destination闭包将返回要保存图片的路径。

    生成并运行您的应用,然后找到某个您最喜欢的图片,单击动作按钮,接着单击Save按钮。目前您还不会看到任何的回应,但是回到应用主界面来,然后单击Downloads标签,这时您就可以看到您下载的照片了。

    您或许会问了:“为什么不直接使用同一个文件路径来保存呢?”。原因是在我们下载图片之前我们并不知道图片的名字。对于 500px.com 来说,服务器始终只会根据图片的尺寸返回1.jpg、2.jpg、3.jpg、4.jpg或者5.jpg这样的名字。我们不能够将相同名字的图片保存在同样的文件夹内。

    我们使用闭包作为Alamofire.download的第三个参数,而不是传递进一个固定的字符串路径。Alamofire 接着就会在一个合适的时间调用该闭包,然后将temporaryURL和NSHTTPURLResponse传递进来作为参数,然后返回一个 URL 的实例,指向硬盘上您有权访问的路径。

    试着保存一些图片,然后返回到下载标签,这时候您会发现,诶?!为啥只有一个文件?闹哪样嘛!

    这是因为文件名并不是独一无二的,因此我们需要实现自己的命名逻辑。用以下代码替换掉downloadPhoto()方法中的注释 //2 下的语句:

    1
    2
    3
    4
    5
    6
    7
    let destination: (NSURL, NSHTTPURLResponse) -> (NSURL) = {
      (temporaryURL, response) in
      if let directoryURL = NSFileManager.defaultManager().URLsForDirectory(.DocumentDirectory, inDomains: .UserDomainMask)[0] as? NSURL {
        return directoryURL.URLByAppendingPathComponent("(self.photoInfo!.id).(response.suggestedFilename)")
      }
      return temporaryURL
    }

    再次说明,let destination是一个闭包,但是我们这次实现了自己的命名逻辑。我们使用在闭包外捕获的图片id,然后将其和服务器建议的名称相连接,这两者之间使用“.”来分隔。

    生成并运行,然后现在您就可以保存多个图片了:

    QQ截图20141203101023.jpg

    文件保存功能目前工作得很好,但是如果给用户显示下载进度的话那岂不是更好?Alamofire 可以很容易地实现显示下载进度指示器。

    用以下代码替换掉downloadPhoto()方法中的注释 //3 下的语句:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    // 4
    let progressIndicatorView = UIProgressView(frame: CGRect(x: 0.0, y: 80.0,  self.view.bounds.width, height: 10.0))
    progressIndicatorView.tintColor = UIColor.blueColor()
    self.view.addSubview(progressIndicatorView)
    // 5
    Alamofire.download(.GET, imageURL, destination).progress {
      (_, totalBytesRead, totalBytesExpectedToRead) in
      dispatch_async(dispatch_get_main_queue()) {
        // 6
        progressIndicatorView.setProgress(Float(totalBytesRead) / Float(totalBytesExpectedToRead), animated: true)
        // 7
        if totalBytesRead == totalBytesExpectedToRead {
          progressIndicatorView.removeFromSuperview()
        }
      }
    }

    我们来依次解释一下这些代码的作用:

    我们使用标准UIProgressView控件来显示图片下载进度。对其进行配置并将其添加到view视图上。

    借助 Alamofire,我们可以将.progress和三个参数进行链接。.progress将定期调用一个闭包,而且这三个参数分别是:bytesRead、totalBytesRead以及totalBytesExpectedToRead。

    简单用totalBytesExpectedToRead除以totalBytesRead,我们就可以得到一个0到1之间的数字,这个数字代表这下载进度。如果下载时间不是瞬时的话,那么这个闭包可能会多次运行。每次运行我们都能够更新屏幕上的进度条。

    一旦下载结束,我们只需从view 视图上移去进度条。

    生成并运行您的应用,找到某个图片并下载它,这是您就可以看到下载进度条。

    QQ截图20141203101113.jpg

    当下载完成时,进度条消失,因此如果您的网络很快的话那么您很有可能会看不到这个进度条。

    我们注意到,downloadPhoto方法仍然是使用在教程第一部分中用过的.resposneJSON()。我们要确保您能够理解响应序列化方法是如何工作的。因此请自行创建一个通用的响应序列化方法.responseObject()来替代.resposneJSON()。如果您想要检查您的解决方案,那么您可以继续向下查看我们是如何解决的:

    downloadPhoto()方法开头的几个语句需要变为以下的形式:

    1
    2
    3
    4
    5
    6
    7
    Alamofire.request(Five100px.Router.PhotoInfo(photoInfo!.id, .XLarge)).validate().responseObject() {
      (_, _, photoInfo: PhotoInfo?, error) in
      if error == nil && photoInfo != nil {
        let imageURL = photoInfo!.url
    .
    .
    .

    这里您应当使用自定义的响应序列化方法,而不是手动解析 JSON 数据。这样可以让您的代码更为简洁。

    优化和刷新

    好的,现在是时候来实现下拉刷新功能了。(强迫症患者们总是在不停地刷新……刷新……再刷新,对吧?泪一般的事实)

    打开PhotoBrowserCollectionViewController.swift,然后用以下代码替换func handleRefresh():

    1
    2
    3
    4
    5
    6
    7
    8
    func handleRefresh() {
      refreshControl.beginRefreshing()
      self.photos.removeAllObjects()
      self.currentPage = 1
      self.collectionView.reloadData()
      refreshControl.endRefreshing()
      populatePhotos()
    }

    上述的代码清空您当前的模式(self.photos),然后重置currentPage,最后刷新 UI。

    生成并运行您的应用,下拉刷新,这时候您就可以看到新的图片出现了:

    633.jpg

    当您快速滑动照片浏览页面的时候,您可能会注意到可以将仍然在请求图片的单元送出屏幕。实际上,图片请求在其结束前会一直运行,但是下载的照片以及相关数据则会被丢弃。

    此外,当您返回到之前的单元,您就必须还得为显示图片而发送网络请求,即使您刚才下载了那幅图片。我们需要改善这个设计,以防止浪费带宽。

    我们可以利用缓存来保存加载过的图像,这样就可以不必再次加载。还有,如果某个单元在网络请求结束前出列了,我们可以取消其所有的网络请求。

    打开PhotoBrowserCollectionViewController.swift,然后在let refreshControl语句上添加以下代码:

    1
    let imageCache = NSCache()

    它创建了一个用于缓存图片的NSCache对象。

    接下来,用以下代码替换collectionView(_:cellForItemAtIndexPath:):

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    override func collectionView(collectionView: UICollectionView, cellForItemAtIndexPath indexPath: NSIndexPath) -> UICollectionViewCell {
      let cell = collectionView.dequeueReusableCellWithReuseIdentifier(PhotoBrowserCellIdentifier, forIndexPath: indexPath) as PhotoBrowserCollectionViewCell
      let imageURL = (photos.objectAtIndex(indexPath.row) as PhotoInfo).url
      // 1
      if cell.request?.request.URLString != imageURL {
        cell.request?.cancel()
      }
      // 2
      if let image = self.imageCache.objectForKey(imageURL) as? UIImage {
        cell.imageView.image = image
      else {
        // 3
        cell.imageView.image = nil
        // 4
        cell.request = Alamofire.request(.GET, imageURL).validate(contentType: ["image/*"]).responseImage() {
          (request, _, image, error) in
          if error == nil && image != nil {
            // 5
            self.imageCache.setObject(image!, forKey: request.URLString)
            // 6
            if request.URLString == cell.request?.request.URLString {
              cell.imageView.image = image
            }
          else {
            /*
            If the cell went off-screen before the image was downloaded, we cancel it and
            an NSURLErrorDomain (-999: cancelled) is returned. This is a normal behavior.
            */
          }
        }
      }
      return cell
    }

    我们来依次解释一下这些代码的作用:

    1.出队的单元可能已经有一个连带的 Alamorire 请求。检查这个请求是否相关,也就是说,检查该请求的 URL 是否和要显示的图片 URL 相匹配,否则就取消请求。

    2.使用可选值绑定来检查该图片是否有缓存版本。如果有的话,使用该缓存版本而不是再次下载。

    3.如果没有相应的缓存版本的话,那么就下载它。然后,出列单元可能已经显示出了另一幅图像。这样子的话,就将其设置为nil,因此当图片在下载时该单元将为空。

    4.从服务器下载图片,然后验证返回响应的content-type。如果返回的不是图片,那么返回值就是为error。因此您就不会再对这个无效的图片进行操作了。这里的关键是我们在单元中存储了 Alamofire 请求对象,当网络异步调用返回时使用。

    5.如果我们没有接收到错误信息,那么就下载相应的图片,并在随后缓存它。

    6.检查单元是否出列以显示新的图片。如果没有的话,则相应地设置单元的图片。

    生成并运行您的应用。您会注意到随着在照片浏览器中来回滚动,图像的加载速度快了许多。我们已经砍掉了不必要的请求,并缓存了已下载的图片以便重复使用,这些操作让我们的网络请求优化了不少。良好的网络处理和灵活的用户界面能够提高用户体验的哦!

    注意:

    某些单元可能会显示为空,而当它全屏显示时又不为空。这并不是您的原因,这是因为 500px.com 并没有这些图片的缩略图。

    接下来该何去何从?

    这里是本系列教程的完整项目。您会看到本教程没有涉及到的很多 UI 元素的设计细节。

    如果您跟随我们的教程完成了学习,那么您现在已经能够很好的使用最受欢迎的第三方库—— Alamofire 了。我们学习了可链接的请求和响应方法、生成自定义响应序列化方法、创建路由并将 URL 参数进行编码、下载文件、使用进度条,以及响应验证。恭喜您获得了一系列成就!当当当!

    Alamofire 同样能验证使用不同方案的服务器。同样,它还可以上传文件和流数据(并未在本教程中提到)。但是您目前对 Alamofire 的了解,相信您能够很快了解如何完成这些任务。

    Alamofire 目前并没有实现 AFNetworking 的全部功能。但是如果您使用 Swift 来开始一个新项目,那么使用 Alamofire 则是最佳选择,因为它已经涵盖了最常用的网络操作。AFNetworking 的最受欢迎的特征之--UIKit 扩展还没有在 Alamofire 当中实现。但是这两个库是可以在同一个项目中并存的。

    如果您先前已经使用过 AFNetworking,并且受不了没有 UIKit 上的类别方法setImageWithURL的话,那么您可能就还得在您的项目中继续使用 AFNetworking。例如,您可以用 Alamofire 调用服务器的 API,然后使用 AFNetworking 来异步显示图像。 AFNetworking 有一个共享的缓存,这样您无需手动管理缓存或者取消网络请求。

    您可以用我们的教程项目完成更多更多的事情。您可以通过使用用户名和密码来避免使用消费者密钥,这样您就可以在 500px.com 上给自己的喜爱的照片投票,或者您可以为评论页面添加页面,让用户体验更加美好~我相信您还能想出更多的点子!

    我希望你能够喜欢这个教程,如果您对本教程或者 Alamofire 有任何疑问,请加入到我们的讨论当中来!

  • 相关阅读:
    【找回数学的感觉】1 再版汉诺塔等
    【算法】7 分不清栈和队列?一张图给你完整体会
    好玩的WPF第四弹:用Viewport2DVisual3D实现3D旋转效果
    我们应该怀念爱因斯坦的五个原因
    【万里征程——Windows App开发】如何在多个页面间读取/保存文件【草稿】
    仅仅是来自深夜的想法
    好玩的WPF第三弹:颤抖吧,地球!消失吧,地球!
    Markdown大赛二等奖
    让JavaScript在Visual Studio 2015中编辑得更容易
    好玩的WPF第二弹:电子表字体显示时间+多彩呼吸灯特效按钮
  • 原文地址:https://www.cnblogs.com/simadi/p/4441876.html
Copyright © 2011-2022 走看看