StoreKit框架详细解析(三) —— 请求应用评级和评论(二)

版本记录

版本号 时间
V1.0 2018.12.18 星期二

前言

StoreKit框架,支持应用内购买和与App Store的互动。接下来几篇我们就一起看一下这个框架。感兴趣的看下面几篇文章。
1. StoreKit框架详细解析(一) —— 基本概览(一)
2. StoreKit框架详细解析(二) —— 请求应用评级和评论(一)

源码

1. Swift

首先看下工程结构

接着看一下sb中的内容

接下来就是源码了

1. NavigationController.swift
import UIKit

final class NavigationController: UINavigationController {
  override var preferredStatusBarStyle: UIStatusBarStyle {
    return .lightContent
  }
}
2. MainViewController.swift
import UIKit

private enum State {
  case loading
  case paging([Recording], next: Int)
  case populated([Recording])
  case empty
  case error(Error)
  
  var currentRecordings: [Recording] {
    switch self {
    case .paging(let recordings, _):
      return recordings
    case .populated(let recordings):
      return recordings
    default:
      return []
    }
  }
}

class MainViewController: UIViewController {
  @IBOutlet private var tableView: UITableView!
  @IBOutlet private var activityIndicator: UIActivityIndicatorView!
  @IBOutlet private var loadingView: UIView!
  @IBOutlet private var emptyView: UIView!
  @IBOutlet private var errorLabel: UILabel!
  @IBOutlet private var errorView: UIView!
  
  private let searchController = UISearchController(searchResultsController: nil)
  private let networkingService = NetworkingService()

  private var state = State.loading {
    didSet {
      setFooterView()
      tableView.reloadData()
    }
  }
  
  override func viewDidLoad() {
    super.viewDidLoad()
    prepareSearchBar()
    loadRecordings()
  }

  override var preferredStatusBarStyle: UIStatusBarStyle {
    return .lightContent
  }
  
  // MARK: - Loading recordings
  
  @objc private func loadRecordings() {
    state = .loading
    loadPage(1)
  }
  
  private func loadPage(_ page: Int) {
    let query = searchController.searchBar.text
    networkingService.fetchRecordings(matching: query, page: page) { [weak self] response in
      guard let self = self else {
        return
      }
      
      self.searchController.searchBar.endEditing(true)
      self.update(response: response)
    }
  }
  
  private func update(response: RecordingsResult) {
    if let error = response.error {
      state = .error(error)
      return
    }
    
    guard let newRecordings = response.recordings,
      !newRecordings.isEmpty else {
        state = .empty
        return
    }
    
    var allRecordings = state.currentRecordings
    allRecordings.append(contentsOf: newRecordings)
    
    if response.hasMorePages {
      state = .paging(allRecordings, next: response.nextPage)
    } else {
      state = .populated(allRecordings)
    }
  }
  
  // MARK: - View Configuration
  
  private func setFooterView() {
    switch state {
    case .error(let error):
      errorLabel.text = error.localizedDescription
      tableView.tableFooterView = errorView
    case .loading:
      tableView.tableFooterView = loadingView
    case .paging:
      tableView.tableFooterView = loadingView
    case .empty:
      tableView.tableFooterView = emptyView
    case .populated:
      tableView.tableFooterView = nil
    }
  }
  
  private func prepareSearchBar() {
    searchController.obscuresBackgroundDuringPresentation = false
    searchController.searchBar.delegate = self
    searchController.searchBar.autocapitalizationType = .none
    searchController.searchBar.autocorrectionType = .no
    
    searchController.searchBar.tintColor = .white
    searchController.searchBar.barTintColor = .white

    let textFieldInSearchBar = UITextField.appearance(whenContainedInInstancesOf: [UISearchBar.self])
    textFieldInSearchBar.defaultTextAttributes = [
      .foregroundColor: UIColor.white
    ]
    
    navigationItem.searchController = searchController
    navigationItem.hidesSearchBarWhenScrolling = false
  }
}

// MARK: -

extension MainViewController: UISearchBarDelegate {
  func searchBar(_ searchBar: UISearchBar,
                 selectedScopeButtonIndexDidChange selectedScope: Int) {
  }
  
  func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
    NSObject.cancelPreviousPerformRequests(withTarget: self,
                                           selector: #selector(loadRecordings),
                                           object: nil)
    
    perform(#selector(loadRecordings), with: nil, afterDelay: 0.5)
  }
}

extension MainViewController: UITableViewDataSource {
  func tableView(_ tableView: UITableView,
                 numberOfRowsInSection section: Int) -> Int {
    return state.currentRecordings.count
  }
  
  func tableView(_ tableView: UITableView,
                 cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    
    guard let cell = tableView.dequeueReusableCell(
      withIdentifier: BirdSoundTableViewCell.reuseIdentifier)
      as? BirdSoundTableViewCell else {
        return UITableViewCell()
    }
    
    cell.load(recording: state.currentRecordings[indexPath.row])
    
    if case .paging(_, let nextPage) = state,
      indexPath.row == state.currentRecordings.count - 1 {
      loadPage(nextPage)
    }
    
    return cell
  }
}
3. SettingsViewController.swift
import UIKit

final class SettingsViewController: UITableViewController {
  // MARK: - UITableViewDelegate

  override func tableView(_ tableView: UITableView,
                          didSelectRowAt indexPath: IndexPath) {
    tableView.deselectRow(at: indexPath, animated: true)

    if indexPath.row == 0 {
      writeReview()
    } else if indexPath.row == 1 {
      share()
    }
  }

  // MARK: - Actions

  private let productURL = URL(string: "https://itunes.apple.com/app/id958625272")!

  private func writeReview() {
    var components = URLComponents(url: productURL, resolvingAgainstBaseURL: false)
    components?.queryItems = [
      URLQueryItem(name: "action", value: "write-review")
    ]

    guard let writeReviewURL = components?.url else {
      return
    }

    UIApplication.shared.open(writeReviewURL)
  }

  private func share() {
    let activityViewController = UIActivityViewController(activityItems: [productURL],
                                                          applicationActivities: nil)

    present(activityViewController, animated: true, completion: nil)
  }
}
4. BirdSoundTableViewCell.swift
import UIKit
import AVKit

class BirdSoundTableViewCell: UITableViewCell {
  static let reuseIdentifier = String(describing: BirdSoundTableViewCell.self)

  @IBOutlet private var nameLabel: UILabel!
  @IBOutlet private var playbackButton: UIButton!
  @IBOutlet private var scientificNameLabel: UILabel!
  @IBOutlet private var countryLabel: UILabel!
  @IBOutlet private var dateLabel: UILabel!
  @IBOutlet private var audioPlayerContainer: UIView!
  
  private var playbackURL: URL?
  private let player = AVPlayer()
  
  private var isPlaying = false {
    didSet {
      let newImage = isPlaying ? #imageLiteral(resourceName: "pause") : #imageLiteral(resourceName: "play")
      playbackButton.setImage(newImage, for: .normal)
      if isPlaying, let url = playbackURL {
        startPlaying(with: url)
      } else {
        stopPlaying()
      }
    }
  }

  override func prepareForReuse() {
    defer { super.prepareForReuse() }
    isPlaying = false
  }
  
  @IBAction private func togglePlayback(_ sender: Any) {
    isPlaying = !isPlaying
  }
  
  func load(recording: Recording) {
    nameLabel.text = recording.friendlyName
    scientificNameLabel.text = recording.scientificName
    countryLabel.text = recording.country
    dateLabel.text = recording.date
    playbackURL = recording.playbackURL
  }

  private func startPlaying(with playbackURL: URL) {
    let playerItem = AVPlayerItem(url: playbackURL)
    NotificationCenter.default.addObserver(self,
                                           selector: #selector(didPlayToEndTime(_:)),
                                           name: .AVPlayerItemDidPlayToEndTime,
                                           object: playerItem)

    player.replaceCurrentItem(with: playerItem)
    player.play()

    AppStoreReviewManager.requestReviewIfAppropriate()
  }

  private func stopPlaying() {
    NotificationCenter.default.removeObserver(self,
                                              name: .AVPlayerItemDidPlayToEndTime,
                                              object: player.currentItem)

    player.pause()
    player.replaceCurrentItem(with: nil)
  }
  
  @objc private func didPlayToEndTime(_: Notification) {
    isPlaying = false
  }
}
5. AppStoreReviewManager.swift
import Foundation
import StoreKit

enum AppStoreReviewManager {
  static let minimumReviewWorthyActionCount = 3

  static func requestReviewIfAppropriate() {
    let defaults = UserDefaults.standard
    let bundle = Bundle.main

    var actionCount = defaults.integer(forKey: .reviewWorthyActionCount)
    actionCount += 1
    defaults.set(actionCount, forKey: .reviewWorthyActionCount)

    guard actionCount >= minimumReviewWorthyActionCount else {
      return
    }

    let bundleVersionKey = kCFBundleVersionKey as String
    let currentVersion = bundle.object(forInfoDictionaryKey: bundleVersionKey) as? String
    let lastVersion = defaults.string(forKey: .lastReviewRequestAppVersion)

    guard lastVersion == nil || lastVersion != currentVersion else {
      return
    }

    SKStoreReviewController.requestReview()

    defaults.set(0, forKey: .reviewWorthyActionCount)
    defaults.set(currentVersion, forKey: .lastReviewRequestAppVersion)
  }
}
6. NetworkingService.swift
import Foundation

enum NetworkError: Error {
  case invalidURL
}

class NetworkingService {
  private let endpoint = "https://www.xeno-canto.org/api/2/recordings"
  
  private var task: URLSessionTask?
  
  func fetchRecordings(matching query: String?, page: Int, onCompletion: @escaping (RecordingsResult) -> Void) {
    func fireErrorCompletion(_ error: Error?) {
      onCompletion(RecordingsResult(recordings: nil, error: error,
                                    currentPage: 0, pageCount: 0))
    }
    
    var queryOrEmpty = "since:1970-01-02"
    
    if let query = query, !query.isEmpty {
      queryOrEmpty = query
    }
    
    var components = URLComponents(string: endpoint)
    components?.queryItems = [
      URLQueryItem(name: "query", value: queryOrEmpty),
      URLQueryItem(name: "page", value: String(page))
    ]
    
    guard let url = components?.url else {
      fireErrorCompletion(NetworkError.invalidURL)
      return
    }
    
    task?.cancel()
    
    task = URLSession.shared.dataTask(with: url) { data, response, error in
      DispatchQueue.main.async {
        if let error = error {
          guard (error as NSError).code != NSURLErrorCancelled else {
            return
          }
          fireErrorCompletion(error)
          return
        }
        
        guard let data = data else {
          fireErrorCompletion(error)
          return
        }
        
        do {
          let result = try JSONDecoder().decode(ServiceResponse.self, from: data)
          
          // For demo purposes, only return 50 at a time
          // This makes it easier to reach the bottom of the results
          let first50 = result.recordings.prefix(50)
          
          onCompletion(RecordingsResult(recordings: Array(first50),
                                        error: nil,
                                        currentPage: result.page,
                                        pageCount: result.numPages))
        } catch {
          fireErrorCompletion(error)
        }
      }
    }
    
    task?.resume()
  }
}
7. ServiceResponse.swift
import Foundation

struct ServiceResponse: Codable {
  let recordings: [Recording]
  let page: Int
  let numPages: Int
}
8. RecordingsResult.swift

import Foundation

struct RecordingsResult {
  let recordings: [Recording]?
  let error: Error?
  let currentPage: Int
  let pageCount: Int
  
  var hasMorePages: Bool {
    return currentPage < pageCount
  }
  
  var nextPage: Int {
    return hasMorePages ? currentPage + 1 : currentPage
  }
}
9. Recording.swift
import Foundation

struct Recording: Codable {
  let genus: String
  let species: String
  let friendlyName: String
  let country: String
  let fileURL: URL
  let date: String
  
  enum CodingKeys: String, CodingKey {
    case genus = "gen"
    case species = "sp"
    case friendlyName = "en"
    case country = "cnt"
    case date
    case fileURL = "file"
  }

  var scientificName: String {
    return "\(genus) \(species)"
  }

  var playbackURL: URL? {
    // The API doesn't return a scheme on the URL, add one to make it valid.
    var components = URLComponents(url: fileURL, resolvingAgainstBaseURL: false)
    components?.scheme = "https"
    return components?.url
  }
}
10. UserDefaults+Key.swift
import Foundation

extension UserDefaults {
  enum Key: String {
    case reviewWorthyActionCount
    case lastReviewRequestAppVersion
  }

  func integer(forKey key: Key) -> Int {
    return integer(forKey: key.rawValue)
  }

  func string(forKey key: Key) -> String? {
    return string(forKey: key.rawValue)
  }

  func set(_ integer: Int, forKey key: Key) {
    set(integer, forKey: key.rawValue)
  }

  func set(_ object: Any?, forKey key: Key) {
    set(object, forKey: key.rawValue)
  }
}

后记

本篇主要讲述了请求应用评级和评论,感兴趣的给个赞或者关注~~~

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,530评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 86,403评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,120评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,770评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,758评论 5 367
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,649评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,021评论 3 398
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,675评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,931评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,659评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,751评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,410评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,004评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,969评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,203评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,042评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,493评论 2 343

推荐阅读更多精彩内容