系统推送的集成(十八) —— APNs从工程配置到自定义通知UI全流程解析(二)

版本记录

版本号 时间
V1.0 2020.05.16 星期六

前言

我们做APP很多时候都需要推送功能,以直播为例,如果你关注的主播开播了,那么就需要向关注这个主播的人发送开播通知,提醒用户去看播,这个只是一个小的方面,具体应用根据公司的业务逻辑而定。前面已经花了很多篇幅介绍了极光推送,其实极光推送无非就是将我们客户端和服务端做的很多东西封装了一下,节省了我们很多处理逻辑和流程,这一篇开始,我们就利用系统的原生推送类结合工程实践说一下系统推送的集成,希望我的讲解能让大家很清楚的理解它。感兴趣的可以看上面几篇。
1. 系统推送的集成(一) —— 基本集成流程(一)
2. 系统推送的集成(二) —— 推送遇到的几个坑之BadDeviceToken问题(一)
3. 系统推送的集成(三) —— 本地和远程通知编程指南之你的App的通知 - 本地和远程通知概览(一)
4. 系统推送的集成(四) —— 本地和远程通知编程指南之你的App的通知 - 管理您的应用程序的通知支持(二)
5. 系统推送的集成(五) —— 本地和远程通知编程指南之你的App的通知 - 调度和处理本地通知(三)
6. 系统推送的集成(六) —— 本地和远程通知编程指南之你的App的通知 - 配置远程通知支持(四)
7. 系统推送的集成(七) —— 本地和远程通知编程指南之你的App的通知 - 修改和显示通知(五)
8. 系统推送的集成(八) —— 本地和远程通知编程指南之苹果推送通知服务APNs - APNs概览(一)
9. 系统推送的集成(九) —— 本地和远程通知编程指南之苹果推送通知服务APNs - 创建远程通知Payload(二)
10. 系统推送的集成(十) —— 本地和远程通知编程指南之苹果推送通知服务APNs - 与APNs通信(三)
11. 系统推送的集成(十一) —— 本地和远程通知编程指南之苹果推送通知服务APNs - Payload Key参考(四)
12. 系统推送的集成(十二) —— 本地和远程通知编程指南之Legacy信息 - 二进制Provider API(一)
13. 系统推送的集成(十三) —— 本地和远程通知编程指南之Legacy信息 - Legacy通知格式(二)
14. 系统推送的集成(十四) —— 发送和处理推送通知流程详解(一)
15. 系统推送的集成(十五) —— 发送和处理推送通知流程详解(二)
16. 系统推送的集成(十六) —— 自定义远程通知(一)
17. 系统推送的集成(十七) —— APNs从工程配置到自定义通知UI全流程解析(一)

源码

1. Swift

首先看下工程组织结构

接着就是看sb中的内容

Main.storyboard
MainInterface.storyboard

下面就是源码了

1. AppDelegate.swift
import UIKit
import SafariServices
import UserNotifications
import CoreData

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
  var window: UIWindow?
  
  func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    UINavigationBar.appearance().barTintColor = .themeGreenColor
    UINavigationBar.appearance().tintColor = .white
    UITabBar.appearance().barTintColor = .themeGreenColor
    UITabBar.appearance().tintColor = .white
    
    registerForPushNotifications()
    
    return true
  }
  
  func application(
    _ application: UIApplication,
    didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
  ) {
    let tokenParts = deviceToken.map { data in String(format: "%02.2hhx", data) }
    let token = tokenParts.joined()
    print("Device Token: \(token)")
  }
  
  func application(
    _ application: UIApplication,
    didFailToRegisterForRemoteNotificationsWithError error: Error
  ) {
    print("Failed to register: \(error)")
  }
  
  func application(
    _ application: UIApplication,
    didReceiveRemoteNotification userInfo: [AnyHashable: Any],
    fetchCompletionHandler completionHandler:
    @escaping (UIBackgroundFetchResult) -> Void
  ) {
    guard let aps = userInfo["aps"] as? [String: AnyObject] else {
      completionHandler(.failed)
      return
    }
    
    if aps["content-available"] as? Int == 1 {
      let podcastStore = PodcastStore.sharedStore
      podcastStore.refreshItems { didLoadNewItems in
        completionHandler(didLoadNewItems ? .newData : .noData)
      }
    }
  }
  
  func registerForPushNotifications() {
    UNUserNotificationCenter.current()
      .requestAuthorization(options: [.alert, .sound, .badge]) { [weak self] granted, error in
        guard let self = self else { return }
        print("Permission granted: \(granted)")
        
        guard granted else { return }
        
        let viewAction = UNNotificationAction(
          identifier: Identifiers.viewAction, title: "View",
          options: [.foreground])
        
        let newsCategory = UNNotificationCategory(
          identifier: Identifiers.newsCategory, actions: [viewAction],
          intentIdentifiers: [], options: [])
        
        UNUserNotificationCenter.current()
          .setNotificationCategories([newsCategory])
        
        self.getNotificationSettings()
    }
  }
  
  func getNotificationSettings() {
    UNUserNotificationCenter.current().getNotificationSettings { settings in
      print("Notification settings: \(settings)")
      guard settings.authorizationStatus == .authorized else { return }
      DispatchQueue.main.async {
        UIApplication.shared.registerForRemoteNotifications()
      }
    }
  }
  
  
  // MARK: UISceneSession Lifecycle
  
  func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
    return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
  }
}
2. SceneDelegate.swift
import UIKit
import UserNotifications
import SafariServices

class SceneDelegate: UIResponder, UIWindowSceneDelegate {
  var window: UIWindow?
  
  func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
    guard let _ = (scene as? UIWindowScene) else { return }
    UNUserNotificationCenter.current().delegate = self
  }
  
  func sceneWillEnterForeground(_ scene: UIScene) {
    NotificationCenter.default.post(name: Notification.Name.appEnteringForeground, object: nil)
  }
}

extension SceneDelegate: UNUserNotificationCenterDelegate {
  func userNotificationCenter(
    _ center: UNUserNotificationCenter,
    didReceive response: UNNotificationResponse,
    withCompletionHandler completionHandler: @escaping () -> Void
  ) {
    let userInfo = response.notification.request.content.userInfo
    
    if let aps = userInfo["aps"] as? [String: AnyObject],
      let category = aps["category"] as? String,
      category == "new_podcast_available",
      let link = userInfo["podcast-link"] as? String {
      
      if let podcast = CoreDataManager.shared.fetchPodcast(byLinkIdentifier: link) {
        guard
          let navController = window?.rootViewController as? UINavigationController,
          let podcastFeedVC = navController.viewControllers[0] as? PodcastFeedTableViewController
          else {
            preconditionFailure("Invalid tab configuration")
        }
        
        let podcastItem = podcast.toPodcastItem()
        podcastFeedVC.loadPodcastDetail(for: podcastItem)
      }
    }
    
    completionHandler()
  }
}
3. PodcastFeedTableViewController.swift
import UIKit

class PodcastFeedTableViewController: UITableViewController {
  let podcastStore = PodcastStore.sharedStore
  
  override var prefersStatusBarHidden: Bool {
    return true
  }
  
  override func viewDidLoad() {
    super.viewDidLoad()
    
    tableView.rowHeight = UITableView.automaticDimension
    tableView.estimatedRowHeight = 75
    
    if let patternImage = UIImage(named: "pattern-grey") {
      let backgroundView = UIView()
      backgroundView.backgroundColor = UIColor(patternImage: patternImage)
      tableView.backgroundView = backgroundView
    }
    
    podcastStore.refreshItems { didLoadNewItems in
      if didLoadNewItems {
        DispatchQueue.main.async {
          self.tableView.reloadData()
        }
      }
    }
    
    NotificationCenter.default.addObserver(
      forName: Notification.Name.appEnteringForeground,
      object: nil,
      queue: nil) { _ in
        self.podcastStore.reloadCachedData()
        self.tableView.reloadData()
    }
  }
  
  override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)
    
    PodcastStore.sharedStore.reloadCachedData()
    tableView.reloadData()
  }
  
  @IBSegueAction
  func createPodcastItemViewController(coder: NSCoder, sender: Any?, segueIdentifier: String?) -> PodcastItemViewController? {
    guard let indexPath = tableView.indexPathsForSelectedRows?.first else {
      return nil
    }
    
    let podcastItem = podcast(for: indexPath)
    return PodcastItemViewController(coder: coder, podcastItem: podcastItem)
  }
  
  private func podcast(for indexPath: IndexPath) -> PodcastItem {
    return podcastStore.items[indexPath.row]
  }
  
  func loadPodcastDetail(for podcast: PodcastItem) {
    let storyboard = UIStoryboard(name: "Main", bundle: nil)
    let detailVC = storyboard.instantiateViewController(identifier: "PodcastItemViewController") { coder in
      return PodcastItemViewController(coder: coder, podcastItem: podcast)
    }
    
    navigationController?.pushViewController(detailVC, animated: true)
  }
}

// MARK: - UITableViewDataSource, UITableViewDelegate
extension PodcastFeedTableViewController {
  override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    return podcastStore.items.count
  }
  
  override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(withIdentifier: "PodcastItemCell", for: indexPath)
    
    if let podcastCell = cell as? PodcastItemCell {
      let podcastItem = podcast(for: indexPath)
      podcastCell.update(with: podcastItem)
    }
    
    return cell
  }
}
4. PodcastItemViewController.swift
import UIKit
import AVKit

class PodcastItemViewController: UIViewController {
  let artworkURLString = "https://koenig-media.raywenderlich.com/uploads/2019/04/Podcast-icon-2019-1400x1400.png"
  
  @IBOutlet weak var titleLabel: UILabel!
  @IBOutlet weak var playerContainerView: UIView!
  @IBOutlet weak var podcastDetailTextView: UITextView!
  @IBOutlet weak var favoriteButton: UIButton!
  
  var playerViewController: AVPlayerViewController!
  var podcastItem: PodcastItem
  
  required init?(coder: NSCoder) {
    fatalError("init(coder:) not implemented")
  }
  
  required init?(coder: NSCoder, podcastItem: PodcastItem) {
    self.podcastItem = podcastItem
    super.init(coder: coder)
  }
  
  override func viewDidAppear(_ animated: Bool) {
    super.viewDidAppear(animated)
    setupNotificationObservers()
    refreshData()
    
    titleLabel.text = podcastItem.title
    let htmlStringData = podcastItem.detail.data(using: .utf8)!
    let options: [NSAttributedString.DocumentReadingOptionKey: Any] = [
      NSAttributedString.DocumentReadingOptionKey.documentType: NSAttributedString.DocumentType.html
    ]
    
    if let attributedHTMLString = try? NSMutableAttributedString(
      data: htmlStringData,
      options: options,
      documentAttributes: nil) {
      podcastDetailTextView.attributedText = attributedHTMLString
    }
    
    updateFavoriteUI()
    
    guard let url = URL(string: artworkURLString) else {
      fatalError("Invalid URL")
    }
    
    downloadImage(for: url)
    
    let player = AVPlayer(url: podcastItem.streamingURL)
    playerViewController.player = player
    playerViewController.player?.play()
  }
  
  override func viewWillDisappear(_ animated: Bool) {
    playerViewController.player?.pause()
    super.viewWillDisappear(animated)
  }
  
  override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
    if segue.identifier == "playerEmbed",
      let playerVC = segue.destination as? AVPlayerViewController {
      playerViewController = playerVC
    }
  }
  
  @IBAction func favoriteButtonTapped(_ sender: UIButton) {
    let favoriteSetting = podcastItem.isFavorite ? false : true
    CoreDataManager.shared.updatePodcaseFavoriteSetting(podcastItem.link, isFavorite: favoriteSetting)
    podcastItem.isFavorite = favoriteSetting
    
    updateFavoriteUI()
  }
  
  private func downloadImage(for url: URL) {
    ImageDownloader.shared.downloadImage(forURL: url) { [weak self] result in
      guard
        let self = self,
        let image = try? result.get()
        else {
          return
      }
      
      DispatchQueue.main.async {
        let imageView = UIImageView(image: image)
        imageView.contentMode = .scaleAspectFit
        guard let contentOverlayView = self.playerViewController.contentOverlayView else {
          return
        }
        
        contentOverlayView.addSubview(imageView)
        imageView.frame = contentOverlayView.bounds
      }
    }
  }
  
  private func setupNotificationObservers() {
    NotificationCenter.default.addObserver(
      forName: Notification.Name.appEnteringForeground,
      object: nil,
      queue: nil
    ) { _ in
      self.refreshData()
      self.updateFavoriteUI()
    }
  }
  
  private func updateFavoriteUI() {
    let symbolString = podcastItem.isFavorite ? "star.fill" : "star"
    favoriteButton.setImage(UIImage(systemName: symbolString), for: .normal)
  }
  
  private func refreshData() {
    let refreshedItem = PodcastStore.sharedStore.reloadData(for: podcastItem)
    self.podcastItem = refreshedItem
  }
}
5. PodcastItemCell.swift
import UIKit

class PodcastItemCell: UITableViewCell {
  func update(with newsItem: PodcastItem) {
    textLabel?.text = newsItem.title
    detailTextLabel?.text = DateParser.displayString(for: newsItem.publishedDate)
  }
}
6. CoreDataManager.swift
import Foundation
import CoreData

public class CoreDataManager {
  public static let shared = CoreDataManager()
  
  private let diskManager = DiskCacheManager()
  private let container: NSPersistentContainer
  
  private init() {
    let persistentContainer = NSPersistentContainer(name: "Wendercast")
    let storeURL = diskManager.databaseURL
    let storeDescription = NSPersistentStoreDescription(url: storeURL)
    persistentContainer.persistentStoreDescriptions = [storeDescription]
    
    persistentContainer.loadPersistentStores { description, error in
      if let error = error {
        preconditionFailure("Unable to configure persistent container: \(error)")
      }
    }
    
    container = persistentContainer
  }
  
  func fetchPodcastItems() -> [PodcastItem] {
    let managedObjectContext = container.viewContext
    let fetchRequest: NSFetchRequest<Podcast> = Podcast.fetchRequest()
    fetchRequest.sortDescriptors = [
      NSSortDescriptor(key: "publishedDate", ascending: false)
    ]
    
    do {
      let results: [Podcast] = try managedObjectContext.fetch(fetchRequest)
      let finalResults = results.map { podcast in
        podcast.toPodcastItem()
      }
      
      return finalResults
    } catch {
      print("Podcast fetch error: \(error)")
      return []
    }
  }
  
  public func savePodcastItems(_ podcastItems: [PodcastItem]) {
    var newPodcasts: [Podcast] = []
    for podcastItem in podcastItems {
      
      let podcastFetchResult = fetchPodcast(byLinkIdentifier: podcastItem.link)
      
      if podcastFetchResult == nil {
        let podcast = Podcast(context: container.viewContext)
        podcast.title = podcastItem.title
        podcast.link = podcastItem.link
        podcast.publishedDate = podcastItem.publishedDate
        podcast.streamingURL = podcastItem.streamingURL
        podcast.isFavorite = podcastItem.isFavorite
        podcast.detail = podcastItem.detail
        
        newPodcasts.append(podcast)
      }
    }
    
    saveContext()
  }
  
  func fetchPodcast(byLinkIdentifier linkIdentifier: String) -> Podcast? {
    let fetchRequest: NSFetchRequest<Podcast> = Podcast.fetchRequest()
    let predicate = NSPredicate(format: "link == %@", linkIdentifier)
    fetchRequest.predicate = predicate
    fetchRequest.fetchLimit = 1
    
    let result = try? container.viewContext.fetch(fetchRequest)
    return result?.first
  }
  
  func updatePodcaseFavoriteSetting(_ podcastLink: String, isFavorite: Bool) {
    guard let podcast = fetchPodcast(byLinkIdentifier: podcastLink) else {
      return
    }
    
    podcast.isFavorite = isFavorite
    
    saveContext()
  }
  
  func saveContext() {
    do {
      try container.viewContext.save()
    } catch {
      preconditionFailure("Unable to save context: \(error)")
    }
  }
}
7. PushIdentifiers.swift
import Foundation

enum Identifiers {
  static let viewAction = "VIEW_IDENTIFIER"
  static let newsCategory = "NEWS_CATEGORY"
}
8. Podcast.swift
import Foundation
import CoreData

@objc(Podcast)
public class Podcast: NSManagedObject {
  public func toPodcastItem() -> PodcastItem {
    guard
      let title = title,
      let publishedDate = publishedDate,
      let link = link,
      let streamingURL = streamingURL,
      let detail = detail
      else {
        preconditionFailure("Invalid podcast item")
    }
    
    return PodcastItem(
      title: title,
      publishedDate: publishedDate,
      link: link,
      streamingURL: streamingURL,
      isFavorite: isFavorite,
      detail: detail
    )
  }
}
9. PodcastItem.swift
import Foundation

public struct PodcastItem: Codable {
  let title: String
  let publishedDate: Date
  let link: String
  let streamingURL: URL
  var isFavorite = false
  let detail: String
}
10. PodcastStore.swift
import Foundation

class PodcastStore {
  static let sharedStore = PodcastStore()
  
  let podcastCacheLoader = PodcastCacheLoader()
  var items: [PodcastItem] = []
  
  init() {
    items = CoreDataManager.shared.fetchPodcastItems()
  }
  
  func refreshItems(_ completion: @escaping (_ didLoadNewItems: Bool) -> Void) {
    PodcastFeedLoader.loadFeed { [weak self] items in
      guard let self = self else {
        completion(false)
        return
      }
      
      let didLoadNewItems = items.count > self.items.count
      self.items = items
      self.podcastCacheLoader.savePodcastItems(items)
      completion(didLoadNewItems)
    }
  }
  
  func reloadCachedData() {
    items = CoreDataManager.shared.fetchPodcastItems()
  }
  
  func reloadData(for podcastItem: PodcastItem) -> PodcastItem {
    let refreshedItem = CoreDataManager.shared.fetchPodcast(byLinkIdentifier: podcastItem.link)
    
    guard let item = refreshedItem else {
      return podcastItem
    }
    
    return item.toPodcastItem()
  }
}
11. PodcastFeedLoader.swift
import Foundation

struct PodcastFeedLoader {
  static let feedURL = "https://www.raywenderlich.com/category/podcast/feed"
  
  static func loadFeed(_ completion: @escaping ([PodcastItem]) -> Void) {
    guard let url = URL(string: feedURL) else { return }
    
    let task = URLSession.shared.dataTask(with: url) { data, _, _ in
      guard let data = data else { return }
      
      let xmlIndexer = SWXMLHash.config { config in
        config.shouldProcessNamespaces = true
      }.parse(data)
      
      let items = xmlIndexer["rss"]["channel"]["item"]
      
      let feedItems = items.compactMap { (indexer: XMLIndexer) -> PodcastItem? in
        if
          let dateString = indexer["pubDate"].element?.text,
          let date = DateParser.dateWithPodcastDateString(dateString),
          let title = indexer["title"].element?.text,
          let link = indexer["link"].element?.text,
          let streamURLString = indexer["enclosure"].element?.attribute(by: "url")?.text,
          let streamURL = URL(string: streamURLString),
          let detail = indexer["description"].element?.text {
          return PodcastItem(
            title: title,
            publishedDate: date,
            link: link,
            streamingURL: streamURL,
            isFavorite: false,
            detail: detail
          )
        }
        
        return nil
      }
      
      completion(feedItems)
    }
    
    task.resume()
  }
}
12. SWXMLHash.swift
import Foundation

let rootElementName = "SWXMLHash_Root_Element"

/// Parser options
public class SWXMLHashOptions {
  internal init() {}
  
  /// determines whether to parse the XML with lazy parsing or not
  public var shouldProcessLazily = false
  
  /// determines whether to parse XML namespaces or not (forwards to
  /// `XMLParser.shouldProcessNamespaces`)
  public var shouldProcessNamespaces = false
}

/// Simple XML parser
public class SWXMLHash {
  let options: SWXMLHashOptions
  
  private init(_ options: SWXMLHashOptions = SWXMLHashOptions()) {
    self.options = options
  }
  
  /**
   Method to configure how parsing works.
   
   - parameters:
   - configAction: a block that passes in an `SWXMLHashOptions` object with
   options to be set
   - returns: an `SWXMLHash` instance
   */
  class public func config(_ configAction: (SWXMLHashOptions) -> Void) -> SWXMLHash {
    let opts = SWXMLHashOptions()
    configAction(opts)
    return SWXMLHash(opts)
  }
  
  /**
   Begins parsing the passed in XML string.
   
   - parameters:
   - xml: an XML string. __Note__ that this is not a URL but a
   string containing XML.
   - returns: an `XMLIndexer` instance that can be iterated over
   */
  public func parse(_ xml: String) -> XMLIndexer {
    return parse(xml.data(using: String.Encoding.utf8)!)
  }
  
  /**
   Begins parsing the passed in XML string.
   
   - parameters:
   - data: a `Data` instance containing XML
   - returns: an `XMLIndexer` instance that can be iterated over
   */
  public func parse(_ data: Data) -> XMLIndexer {
    let parser: SimpleXmlParser = options.shouldProcessLazily
      ? LazyXMLParser(options)
      : FullXMLParser(options)
    return parser.parse(data)
  }
  
  /**
   Method to parse XML passed in as a string.
   
   - parameter xml: The XML to be parsed
   - returns: An XMLIndexer instance that is used to look up elements in the XML
   */
  class public func parse(_ xml: String) -> XMLIndexer {
    return SWXMLHash().parse(xml)
  }
  
  /**
   Method to parse XML passed in as a Data instance.
   
   - parameter data: The XML to be parsed
   - returns: An XMLIndexer instance that is used to look up elements in the XML
   */
  class public func parse(_ data: Data) -> XMLIndexer {
    return SWXMLHash().parse(data)
  }
  
  /**
   Method to lazily parse XML passed in as a string.
   
   - parameter xml: The XML to be parsed
   - returns: An XMLIndexer instance that is used to look up elements in the XML
   */
  class public func lazy(_ xml: String) -> XMLIndexer {
    return config { conf in conf.shouldProcessLazily = true }.parse(xml)
  }
  
  /**
   Method to lazily parse XML passed in as a Data instance.
   
   - parameter data: The XML to be parsed
   - returns: An XMLIndexer instance that is used to look up elements in the XML
   */
  class public func lazy(_ data: Data) -> XMLIndexer {
    return config { conf in conf.shouldProcessLazily = true }.parse(data)
  }
}

struct Stack<T> {
  var items = [T]()
  mutating func push(_ item: T) {
    items.append(item)
  }
  mutating func pop() -> T {
    return items.removeLast()
  }
  mutating func drop() {
    let _ = pop()
  }
  mutating func removeAll() {
    items.removeAll(keepingCapacity: false)
  }
  func top() -> T {
    return items[items.count - 1]
  }
}

protocol SimpleXmlParser {
  init(_ options: SWXMLHashOptions)
  func parse(_ data: Data) -> XMLIndexer
}

#if os(Linux)

extension XMLParserDelegate {
  
  func parserDidStartDocument(_ parser: Foundation.XMLParser) { }
  func parserDidEndDocument(_ parser: Foundation.XMLParser) { }
  
  func parser(_ parser: Foundation.XMLParser,
              foundNotationDeclarationWithName name: String,
              publicID: String?,
              systemID: String?) { }
  
  func parser(_ parser: Foundation.XMLParser,
              foundUnparsedEntityDeclarationWithName name: String,
              publicID: String?,
              systemID: String?,
              notationName: String?) { }
  
  func parser(_ parser: Foundation.XMLParser,
              foundAttributeDeclarationWithName attributeName: String,
              forElement elementName: String,
              type: String?,
              defaultValue: String?) { }
  
  func parser(_ parser: Foundation.XMLParser,
              foundElementDeclarationWithName elementName: String,
              model: String) { }
  
  func parser(_ parser: Foundation.XMLParser,
              foundInternalEntityDeclarationWithName name: String,
              value: String?) { }
  
  func parser(_ parser: Foundation.XMLParser,
              foundExternalEntityDeclarationWithName name: String,
              publicID: String?,
              systemID: String?) { }
  
  func parser(_ parser: Foundation.XMLParser,
              didStartElement elementName: String,
              namespaceURI: String?,
              qualifiedName qName: String?,
              attributes attributeDict: [String : String]) { }
  
  func parser(_ parser: Foundation.XMLParser,
              didEndElement elementName: String,
              namespaceURI: String?,
              qualifiedName qName: String?) { }
  
  func parser(_ parser: Foundation.XMLParser,
              didStartMappingPrefix prefix: String,
              toURI namespaceURI: String) { }
  
  func parser(_ parser: Foundation.XMLParser, didEndMappingPrefix prefix: String) { }
  
  func parser(_ parser: Foundation.XMLParser, foundCharacters string: String) { }
  
  func parser(_ parser: Foundation.XMLParser,
              foundIgnorableWhitespace whitespaceString: String) { }
  
  func parser(_ parser: Foundation.XMLParser,
              foundProcessingInstructionWithTarget target: String,
              data: String?) { }
  
  func parser(_ parser: Foundation.XMLParser, foundComment comment: String) { }
  
  func parser(_ parser: Foundation.XMLParser, foundCDATA CDATABlock: Data) { }
  
  func parser(_ parser: Foundation.XMLParser,
              resolveExternalEntityName name: String,
              systemID: String?) -> Data? { return nil }
  
  func parser(_ parser: Foundation.XMLParser, parseErrorOccurred parseError: NSError) { }
  
  func parser(_ parser: Foundation.XMLParser,
              validationErrorOccurred validationError: NSError) { }
}

#endif

/// The implementation of XMLParserDelegate and where the lazy parsing actually happens.
class LazyXMLParser: NSObject, SimpleXmlParser, XMLParserDelegate {
  required init(_ options: SWXMLHashOptions) {
    self.options = options
    super.init()
  }
  
  var root = XMLElement(name: rootElementName)
  var parentStack = Stack<XMLElement>()
  var elementStack = Stack<String>()
  
  var data: Data?
  var ops: [IndexOp] = []
  let options: SWXMLHashOptions
  
  func parse(_ data: Data) -> XMLIndexer {
    self.data = data
    return XMLIndexer(self)
  }
  
  func startParsing(_ ops: [IndexOp]) {
    // clear any prior runs of parse... expected that this won't be necessary,
    // but you never know
    parentStack.removeAll()
    root = XMLElement(name: rootElementName)
    parentStack.push(root)
    
    self.ops = ops
    let parser = Foundation.XMLParser(data: data!)
    parser.shouldProcessNamespaces = options.shouldProcessNamespaces
    parser.delegate = self
    _ = parser.parse()
  }
  
  func parser(_ parser: Foundation.XMLParser,
              didStartElement elementName: String,
              namespaceURI: String?,
              qualifiedName qName: String?,
              attributes attributeDict: [String: String]) {
    
    elementStack.push(elementName)
    
    if !onMatch() {
      return
    }
    #if os(Linux)
    let attributeNSDict = NSDictionary(
      objects: attributeDict.values.flatMap({ $0 as? AnyObject }),
      forKeys: attributeDict.keys.map({ NSString(string: $0) as NSObject })
    )
    let currentNode = parentStack.top().addElement(elementName, withAttributes: attributeNSDict)
    #else
    let currentNode = parentStack
      .top()
      .addElement(elementName, withAttributes: attributeDict as NSDictionary)
    #endif
    parentStack.push(currentNode)
  }
  
  func parser(_ parser: Foundation.XMLParser, foundCharacters string: String) {
    if !onMatch() {
      return
    }
    
    let current = parentStack.top()
    
    current.addText(string)
  }
  
  func parser(_ parser: Foundation.XMLParser,
              didEndElement elementName: String,
              namespaceURI: String?,
              qualifiedName qName: String?) {
    
    let match = onMatch()
    
    elementStack.drop()
    
    if match {
      parentStack.drop()
    }
  }
  
  func onMatch() -> Bool {
    // we typically want to compare against the elementStack to see if it matches ops, *but*
    // if we're on the first element, we'll instead compare the other direction.
    if elementStack.items.count > ops.count {
      return elementStack.items.starts(with: ops.map { $0.key })
    } else {
      return ops.map { $0.key }.starts(with: elementStack.items)
    }
  }
}

/// The implementation of XMLParserDelegate and where the parsing actually happens.
class FullXMLParser: NSObject, SimpleXmlParser, XMLParserDelegate {
  required init(_ options: SWXMLHashOptions) {
    self.options = options
    super.init()
  }
  
  var root = XMLElement(name: rootElementName)
  var parentStack = Stack<XMLElement>()
  let options: SWXMLHashOptions
  
  func parse(_ data: Data) -> XMLIndexer {
    // clear any prior runs of parse... expected that this won't be necessary,
    // but you never know
    parentStack.removeAll()
    
    parentStack.push(root)
    
    let parser = Foundation.XMLParser(data: data)
    parser.shouldProcessNamespaces = options.shouldProcessNamespaces
    parser.delegate = self
    _ = parser.parse()
    
    return XMLIndexer(root)
  }
  
  func parser(_ parser: Foundation.XMLParser,
              didStartElement elementName: String,
              namespaceURI: String?,
              qualifiedName qName: String?,
              attributes attributeDict: [String: String]) {
    #if os(Linux)
    let attributeNSDict = NSDictionary(
      objects: attributeDict.values.flatMap({ $0 as? AnyObject }),
      forKeys: attributeDict.keys.map({ NSString(string: $0) as NSObject })
    )
    let currentNode = parentStack.top().addElement(elementName, withAttributes: attributeNSDict)
    #else
    let currentNode = parentStack
      .top()
      .addElement(elementName, withAttributes: attributeDict as NSDictionary)
    #endif
    parentStack.push(currentNode)
  }
  
  func parser(_ parser: Foundation.XMLParser, foundCharacters string: String) {
    let current = parentStack.top()
    
    current.addText(string)
  }
  
  func parser(_ parser: Foundation.XMLParser,
              didEndElement elementName: String,
              namespaceURI: String?,
              qualifiedName qName: String?) {
    
    parentStack.drop()
  }
}

/// Represents an indexed operation against a lazily parsed `XMLIndexer`
public class IndexOp {
  var index: Int
  let key: String
  
  init(_ key: String) {
    self.key = key
    self.index = -1
  }
  
  func toString() -> String {
    if index >= 0 {
      return key + " " + index.description
    }
    
    return key
  }
}

/// Represents a collection of `IndexOp` instances. Provides a means of iterating them
/// to find a match in a lazily parsed `XMLIndexer` instance.
public class IndexOps {
  var ops: [IndexOp] = []
  
  let parser: LazyXMLParser
  
  init(parser: LazyXMLParser) {
    self.parser = parser
  }
  
  func findElements() -> XMLIndexer {
    parser.startParsing(ops)
    let indexer = XMLIndexer(parser.root)
    var childIndex = indexer
    for op in ops {
      childIndex = childIndex[op.key]
      if op.index >= 0 {
        childIndex = childIndex[op.index]
      }
    }
    ops.removeAll(keepingCapacity: false)
    return childIndex
  }
  
  func stringify() -> String {
    var s = ""
    for op in ops {
      s += "[" + op.toString() + "]"
    }
    return s
  }
}

/// Error type that is thrown when an indexing or parsing operation fails.
public enum IndexingError: Error {
  case Attribute(attr: String)
  case AttributeValue(attr: String, value: String)
  case Key(key: String)
  case Index(idx: Int)
  case Init(instance: AnyObject)
  case Error
}

/// Returned from SWXMLHash, allows easy element lookup into XML data.
public enum XMLIndexer: Sequence {
  case element(XMLElement)
  case list([XMLElement])
  case stream(IndexOps)
  case xmlError(IndexingError)
  
  /// The underlying XMLElement at the currently indexed level of XML.
  public var element: XMLElement? {
    switch self {
    case .element(let elem):
      return elem
    case .stream(let ops):
      let list = ops.findElements()
      return list.element
    default:
      return nil
    }
  }
  
  /// All elements at the currently indexed level
  public var all: [XMLIndexer] {
    switch self {
    case .list(let list):
      var xmlList = [XMLIndexer]()
      for elem in list {
        xmlList.append(XMLIndexer(elem))
      }
      return xmlList
    case .element(let elem):
      return [XMLIndexer(elem)]
    case .stream(let ops):
      let list = ops.findElements()
      return list.all
    default:
      return []
    }
  }
  
  /// All child elements from the currently indexed level
  public var children: [XMLIndexer] {
    var list = [XMLIndexer]()
    for elem in all.map({ $0.element! }).compactMap({ $0 }) {
      for elem in elem.xmlChildren {
        list.append(XMLIndexer(elem))
      }
    }
    return list
  }
  
  /**
   Allows for element lookup by matching attribute values.
   
   - parameters:
   - attr: should the name of the attribute to match on
   - value: should be the value of the attribute to match on
   - throws: an XMLIndexer.xmlError if an element with the specified attribute isn't found
   - returns: instance of XMLIndexer
   */
  public func withAttr(_ attr: String, _ value: String) throws -> XMLIndexer {
    switch self {
    case .stream(let opStream):
      let match = opStream.findElements()
      return try match.withAttr(attr, value)
    case .list(let list):
      if let elem = list.filter({$0.attribute(by: attr)?.text == value}).first {
        return .element(elem)
      }
      throw IndexingError.AttributeValue(attr: attr, value: value)
    case .element(let elem):
      if elem.attribute(by: attr)?.text == value {
        return .element(elem)
      }
      throw IndexingError.AttributeValue(attr: attr, value: value)
    default:
      throw IndexingError.Attribute(attr: attr)
    }
  }
  
  /**
   Initializes the XMLIndexer
   
   - parameter _: should be an instance of XMLElement, but supports other values for error handling
   - throws: an Error if the object passed in isn't an XMLElement or LaxyXMLParser
   */
  public init(_ rawObject: AnyObject) throws {
    switch rawObject {
    case let value as XMLElement:
      self = .element(value)
    case let value as LazyXMLParser:
      self = .stream(IndexOps(parser: value))
    default:
      throw IndexingError.Init(instance: rawObject)
    }
  }
  
  /**
   Initializes the XMLIndexer
   
   - parameter _: an instance of XMLElement
   */
  public init(_ elem: XMLElement) {
    self = .element(elem)
  }
  
  init(_ stream: LazyXMLParser) {
    self = .stream(IndexOps(parser: stream))
  }
  
  /**
   Find an XML element at the current level by element name
   
   - parameter key: The element name to index by
   - returns: instance of XMLIndexer to match the element (or elements) found by key
   - throws: Throws an XMLIndexingError.Key if no element was found
   */
  public func byKey(_ key: String) throws -> XMLIndexer {
    switch self {
    case .stream(let opStream):
      let op = IndexOp(key)
      opStream.ops.append(op)
      return .stream(opStream)
    case .element(let elem):
      let match = elem.xmlChildren.filter({ $0.name == key })
      if !match.isEmpty {
        if match.count == 1 {
          return .element(match[0])
        } else {
          return .list(match)
        }
      }
      fallthrough
    default:
      throw IndexingError.Key(key: key)
    }
  }
  
  /**
   Find an XML element at the current level by element name
   
   - parameter key: The element name to index by
   - returns: instance of XMLIndexer to match the element (or elements) found by
   */
  public subscript(key: String) -> XMLIndexer {
    do {
      return try self.byKey(key)
    } catch let error as IndexingError {
      return .xmlError(error)
    } catch {
      return .xmlError(IndexingError.Key(key: key))
    }
  }
  
  /**
   Find an XML element by index within a list of XML Elements at the current level
   
   - parameter index: The 0-based index to index by
   - throws: XMLIndexer.xmlError if the index isn't found
   - returns: instance of XMLIndexer to match the element (or elements) found by index
   */
  public func byIndex(_ index: Int) throws -> XMLIndexer {
    switch self {
    case .stream(let opStream):
      opStream.ops[opStream.ops.count - 1].index = index
      return .stream(opStream)
    case .list(let list):
      if index <= list.count {
        return .element(list[index])
      }
      return .xmlError(IndexingError.Index(idx: index))
    case .element(let elem):
      if index == 0 {
        return .element(elem)
      }
      fallthrough
    default:
      return .xmlError(IndexingError.Index(idx: index))
    }
  }
  
  /**
   Find an XML element by index
   
   - parameter index: The 0-based index to index by
   - returns: instance of XMLIndexer to match the element (or elements) found by index
   */
  public subscript(index: Int) -> XMLIndexer {
    do {
      return try byIndex(index)
    } catch let error as IndexingError {
      return .xmlError(error)
    } catch {
      return .xmlError(IndexingError.Index(idx: index))
    }
  }
  
  typealias GeneratorType = XMLIndexer
  
  /**
   Method to iterate (for-in) over the `all` collection
   
   - returns: an array of `XMLIndexer` instances
   */
  public func makeIterator() -> IndexingIterator<[XMLIndexer]> {
    return all.makeIterator()
  }
}

/// XMLIndexer extensions
/*
 extension XMLIndexer: Boolean {
 /// True if a valid XMLIndexer, false if an error type
 public var boolValue: Bool {
 switch self {
 case .xmlError:
 return false
 default:
 return true
 }
 }
 }
 */

extension XMLIndexer: CustomStringConvertible {
  /// The XML representation of the XMLIndexer at the current level
  public var description: String {
    switch self {
    case .list(let list):
      return list.map { $0.description }.joined(separator: "")
    case .element(let elem):
      if elem.name == rootElementName {
        return elem.children.map { $0.description }.joined(separator: "")
      }
      
      return elem.description
    default:
      return ""
    }
  }
}

extension IndexingError: CustomStringConvertible {
  /// The description for the `IndexingError`.
  public var description: String {
    switch self {
    case .Attribute(let attr):
      return "XML Attribute Error: Missing attribute [\"\(attr)\"]"
    case .AttributeValue(let attr, let value):
      return "XML Attribute Error: Missing attribute [\"\(attr)\"] with value [\"\(value)\"]"
    case .Key(let key):
      return "XML Element Error: Incorrect key [\"\(key)\"]"
    case .Index(let index):
      return "XML Element Error: Incorrect index [\"\(index)\"]"
    case .Init(let instance):
      return "XML Indexer Error: initialization with Object [\"\(instance)\"]"
    case .Error:
      return "Unknown Error"
    }
  }
}

/// Models content for an XML doc, whether it is text or XML
public protocol XMLContent: CustomStringConvertible { }

/// Models a text element
public class TextElement: XMLContent {
  /// The underlying text value
  public let text: String
  init(text: String) {
    self.text = text
  }
}

public struct XMLAttribute {
  public let name: String
  public let text: String
  init(name: String, text: String) {
    self.name = name
    self.text = text
  }
}

/// Models an XML element, including name, text and attributes
public class XMLElement: XMLContent {
  /// The name of the element
  public let name: String
  
  // swiftlint:disable line_length
  /// The attributes of the element
  @available(*, deprecated, message: "See `allAttributes` instead, which introduces the XMLAttribute type over a simple String type")
  public var attributes: [String:String] {
    var attrMap = [String: String]()
    for (name, attr) in allAttributes {
      attrMap[name] = attr.text
    }
    return attrMap
  }
  // swiftlint:enable line_length
  
  /// All attributes
  public var allAttributes = [String: XMLAttribute]()
  
  public func attribute(by name: String) -> XMLAttribute? {
    return allAttributes[name]
  }
  
  /// The inner text of the element, if it exists
  public var text: String? {
    return children
      .map({ $0 as? TextElement })
      .compactMap({ $0 })
      .reduce("", { $0 + $1.text })
  }
  
  /// All child elements (text or XML)
  public var children = [XMLContent]()
  var count: Int = 0
  var index: Int
  
  var xmlChildren: [XMLElement] {
    return children.map { $0 as? XMLElement }.compactMap { $0 }
  }
  
  /**
   Initialize an XMLElement instance
   
   - parameters:
   - name: The name of the element to be initialized
   - index: The index of the element to be initialized
   */
  init(name: String, index: Int = 0) {
    self.name = name
    self.index = index
  }
  
  /**
   Adds a new XMLElement underneath this instance of XMLElement
   
   - parameters:
   - name: The name of the new element to be added
   - withAttributes: The attributes dictionary for the element being added
   - returns: The XMLElement that has now been added
   */
  func addElement(_ name: String, withAttributes attributes: NSDictionary) -> XMLElement {
    let element = XMLElement(name: name, index: count)
    count += 1
    
    children.append(element)
    
    for (keyAny, valueAny) in attributes {
      if let key = keyAny as? String,
        let value = valueAny as? String {
        element.allAttributes[key] = XMLAttribute(name: key, text: value)
      }
    }
    
    return element
  }
  
  func addText(_ text: String) {
    let elem = TextElement(text: text)
    
    children.append(elem)
  }
}

extension TextElement: CustomStringConvertible {
  /// The text value for a `TextElement` instance.
  public var description: String {
    return text
  }
}

extension XMLAttribute: CustomStringConvertible {
  /// The textual representation of an `XMLAttribute` instance.
  public var description: String {
    return "\(name)=\"\(text)\""
  }
}

extension XMLElement: CustomStringConvertible {
  /// The tag, attributes and content for a `XMLElement` instance (<elem id="foo">content</elem>)
  public var description: String {
    var attributesString = allAttributes.map { $0.1.description }.joined(separator: " ")
    if !attributesString.isEmpty {
      attributesString = " " + attributesString
    }
    
    if !children.isEmpty {
      var xmlReturn = [String]()
      xmlReturn.append("<\(name)\(attributesString)>")
      for child in children {
        xmlReturn.append(child.description)
      }
      xmlReturn.append("</\(name)>")
      return xmlReturn.joined(separator: "")
    }
    
    if text != nil {
      return "<\(name)\(attributesString)>\(text!)</\(name)>"
    } else {
      return "<\(name)\(attributesString)/>"
    }
  }
}

// Workaround for "'XMLElement' is ambiguous for type lookup in this context" error on macOS.
//
// On macOS, `XMLElement` is defined in Foundation.
// So, the code referencing `XMLElement` generates above error.
// Following code allow to using `SWXMLhash.XMLElement` in client codes.
extension SWXMLHash {
  public typealias XMLElement = SWXMLHashXMLElement
}

public  typealias SWXMLHashXMLElement = XMLElement
13. UIColor+Theme.swift
import UIKit

extension UIColor {
  static var themeGreenColor: UIColor {
    return UIColor(red: 0.0, green: 104/255.0, blue: 55/255.0, alpha: 1)
  }
}
14. DateParser.swift
import Foundation

struct DateParser {
  static let dateFormatter: DateFormatter = {
    let formatter = DateFormatter()
    formatter.locale = Locale(identifier: "en_US")
    return formatter
  }()
  
  //Wed, 04 Nov 2015 21:00:14 +0000
  static func dateWithPodcastDateString(_ dateString: String) -> Date? {
    dateFormatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss Z"
    return dateFormatter.date(from: dateString)
  }
  
  static func displayString(for date: Date) -> String {
    dateFormatter.dateFormat = "HH:mm MMMM dd, yyyy"
    return dateFormatter.string(from: date)
  }
}
15. PodcastCacheLoader.swift
import Foundation

public class PodcastCacheLoader {
  let cacheManager = DiskCacheManager()
  
  public init() {}
  
  public func savePodcastItems(_ podcastItems: [PodcastItem]) {
    CoreDataManager.shared.savePodcastItems(podcastItems)
  }
}
16. DiskCacheManager.swift
import Foundation

class DiskCacheManager {
  let groupIdentifier = "<#group identifier here#>"
  let databaseName = "Wendercast.sqlite"
  
  var groupDirectoryLocation: URL {
    guard let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: groupIdentifier) else {
      preconditionFailure("Invalid group configuration")
    }
    
    return containerURL
  }
  
  var databaseURL: URL {
    return groupDirectoryLocation.appendingPathComponent(databaseName)
  }
}
17. Notification+Name.swift
import Foundation

extension Notification.Name {
  static let appEnteringForeground = Notification.Name("com.app.foregrouns")
}
18. ImageDownloader.swift
import UIKit

public class ImageDownloader {
  public static let shared = ImageDownloader()
  
  private init () { }
  
  public func downloadImage(forURL url: URL, completion: @escaping (Result<UIImage, Error>) -> Void) {
    let task = URLSession.shared.dataTask(with: url) { data, response, error in
      if let error = error {
        completion(.failure(error))
        return
      }
      
      guard let data = data else {
        completion(.failure(DownloadError.emptyData))
        return
      }
      
      guard let image = UIImage(data: data) else {
        completion(.failure(DownloadError.invalidImage))
        return
      }
      
      completion(.success(image))
    }
    
    task.resume()
  }
}
19. NetworkError.swift
import Foundation

public enum DownloadError: Error {
  case emptyData
  case invalidImage
}

后记

本篇主要讲述了APNs从工程配置到自定义通知UI全流程解析,感兴趣的给个赞或者关注~~~

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