swift通知栏推送
by Neo Ighodaro
由新Ighodaro
如何使用Swift和Laravel使用推送通知创建iOS加密跟踪应用 (How to create an iOS crypto tracking app with push notifications using Swift and Laravel)
第2部分 (Part 2)
You will need the following installed on your machine: Xcode, the Laravel CLI, SQLite and Cocoapods. Familiarity with the Xcode IDE will be helpful. You should have completed part one of the series.
您将需要在计算机上安装以下软件:Xcode,Laravel CLI,SQLite和Cocoapods。 熟悉Xcode IDE将有所帮助。 您应该已经完成了系列的第一部分。
In the first part of this article, we started developing our cryptocurrency alert application. We developed the backend of the application that will power the iOS application. As it stands, our backend application can return settings for a device based on its UUID, save the settings for a device based on its UUID and also figure out what devices to send push notifications to when the currencies update.
在本文的第一部分 ,我们开始开发我们的加密货币警报应用程序。 我们开发了将支持iOS应用程序的应用程序后端。 就目前而言,我们的后端应用程序可以根据其UUID返回设备的设置,根据其UUID保存设备的设置,还可以确定在货币更新时向哪些设备发送推送通知。
In this part, we will focus on creating the iOS application using Swift and Xcode.
在这一部分中,我们将重点介绍使用Swift和Xcode创建iOS应用程序。
先决条件 (Prerequisites)
To follow along you need the following requirements:
要遵循,您需要满足以下要求:
Completed part one of this article.
完成了本文的第一部分 。
Xcode installed on your machine.
Xcode安装在您的计算机上。
- Knowledge of the Xcode IDE. 了解Xcode IDE。
Basic knowledge using the Laravel framework.
使用Laravel框架的基本知识。
Basic knowledge of the Swift programming language.
Swift编程语言的基本知识。
Laravel CLI installed on your machine.
您的计算机上已安装Laravel CLI 。
SQLite installed on your machine. Installation guide.
在您的计算机上安装了SQLite。 安装指南 。
Cocoapods installed on your machine.
您的机器上安装了可可足类 。
Pusher Beams and Channels application.
我们将要建设的 (What we will be building)
We already started out by building the backend of the application using Laravel. So next, we will build the iOS application using Swift. If you want to test the push notifications then you will need to run the application on a live device.
我们已经开始使用Laravel构建应用程序的后端。 因此,接下来,我们将使用Swift构建iOS应用程序。 如果要测试推送通知,则需要在实时设备上运行该应用程序。
客户端应用程序将如何工作 (How the client application will work)
For the client app, the iOS application, we will create a simple list that will display the available currencies and the current prices to the dollar. Whenever the price of the cryptocurrency changes, we will trigger an event using Pusher Channels so the prices are updated.
对于客户端应用程序(iOS应用程序),我们将创建一个简单列表,该列表将显示可用货币和美元的当前价格。 每当加密货币的价格发生变化时,我们都会使用Pusher Channels触发事件,以便更新价格。
From the application, you will be able to set a minimum and maximum price change when you want to be alerted. For instance, you can configure the application to send a push notification to the application when the price of one Etherium (ETH) goes below $500. You can also configure the application to receive a notification when the price of Bitcoin goes above $5000.
通过该应用程序,您可以在想要收到警报时设置最小和最大价格变化。 例如,您可以将应用程序配置为在一个以太网(ETH)的价格低于$ 500时向应用程序发送推送通知。 您还可以将应用程序配置为在比特币价格超过5000美元时接收通知。
应用程序的外观 (How the application will look)
When we are done with the application, here’s how the application will look:
处理完应用程序后,以下是该应用程序的外观:
Let’s get started.
让我们开始吧。
设置您的客户端应用程序 (Setting up your client application)
Launch Xcode and click Create a new Xcode project. Select Single View App and click Next. Enter your Product Name, we will call our project cryptoalat, and select Swift from the Language options. You can also change any other detail you wish to on the screen then click Next.
启动Xcode,然后单击“ 创建新的Xcode项目” 。 选择Single View App ,然后单击Next 。 输入您的产品名称 ,我们将我们的项目称为cryptoalat ,然后从“ 语言”选项中选择“ 快速 ”。 您还可以在屏幕上更改任何其他想要的详细信息,然后单击“ 下一步” 。
安装依赖 (Installing dependencies)
Now you have your Xcode project. Close Xcode and open a terminal window. cd
to the iOS project directory in terminal and run the command below to create a Podfile:
现在,您有了Xcode项目。 关闭Xcode并打开一个终端窗口。 cd
到终端中的iOS项目目录,然后运行以下命令来创建Podfile:
$ pod init
The Podfile is a specification that describes the dependencies of the targets of one or more Xcode projects. The file should simply be named Podfile. All the examples in the guides are based on CocoaPods version 1.0 and onwards. — Cocoapods Guides
Podfile是一种规范,描述了一个或多个Xcode项目的目标依赖关系。 该文件应简单地命名为Podfile。 指南中的所有示例均基于CocoaPods 1.0版及更高版本。 — 椰壳纲指南
This will generate a new file called Podfile
in the root of your project. Open this file in any editor and update the file as seen below:
这将在项目的根目录中生成一个名为Podfile
的新文件。 在任何编辑器中打开此文件并更新文件,如下所示:
// File: Podfile platform :ios, '11.0'
target 'cryptoalat' do use_frameworks!
pod 'Alamofire', '~> 4.7.2' pod 'PushNotifications', '~> 0.10.8' pod 'PusherSwift', '~> 6.1.0' pod 'NotificationBannerSwift', '~> 1.6.3' end
If you used a project name other than cryptoalat, then change it in the Podfile to match your project’s target name.
如果您使用的项目名称不是cryptoalat,则在Podfile中对其进行更改以匹配您的项目的目标名称。
Go to terminal and run the command below to install your dependencies:
转到终端并运行以下命令以安装依赖项:
$ pod install
When the installation is complete, you will have a *.xcworkspace
file in the root of your project. Open this file in Xcode and let’s start developing our cryptocurrency alert application.
安装完成后,项目的根目录中将有一个*.xcworkspace
文件。 在Xcode中打开此文件,让我们开始开发我们的加密货币警报应用程序。
生成iOS应用程序 (Building the iOS application)
创建故事板 (Creating our storyboard)
The first thing we need to do is design our storyboard for the application. This is what we want the storyboard to look like when we are done.
我们需要做的第一件事是为应用程序设计故事板。 这就是我们希望故事板完成后的样子。
Open the Main.storyboard
file and design as seen above.
打开Main.storyboard
文件并进行设计,如上所示。
Above we have three scenes. The first scene, which is the entry point, is the launch scene. We then draw a manual segue with an identifier called Main. Then we set the segue Kind to Present Modally. This will present the next scene which is a navigation view controller. Navigation controllers already have an attached root view controller by default.
上面我们有三个场景。 第一个场景(即入口点)是发射场景。 然后,我们绘制一个名为Main的手动序列。 然后我们将segue Kind设置为Modal Present 。 这将显示下一个场景,它是导航视图控制器。 默认情况下,导航控制器已经具有附加的根视图控制器。
We will use this attached view controller, which is a TableViewController
, as the main view for our application. It’ll list the available currencies and show us a text field that allows us to change the setting for that currency when it is tapped.
我们将使用这个附加的视图控制器TableViewController
作为应用程序的主视图。 它将列出可用的货币,并显示一个文本字段,允许我们在点击该货币时更改其设置。
On the third scene, we set the reuse identifier of the cells to coin and we drag two labels to the prototype cell. The first label will be for the coin name and the second label will be for the price.
在第三个场景中,我们将单元的重用标识符设置为硬币,然后将两个标签拖到原型单元上。 第一个标签用于硬币名称,第二个标签用于价格。
Now that we have the scenes, let’s create some controllers and view classes and connect them to our storyboard scenes.
现在我们有了场景,让我们创建一些控制器并查看类并将它们连接到我们的情节提要场景。
创建您的控制器 (Creating your controllers)
In Xcode, create a new class LaunchViewController
and paste the contents of the file below into it:
在Xcode中,创建一个新的类LaunchViewController
并将以下文件的内容粘贴到其中:
import UIKit
class LaunchViewController: UIViewController {
override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated)
SettingsService.shared.loadSettings { self.routeToMainController() } }
fileprivate func routeToMainController() { performSegue(withIdentifier: "Main", sender: self) } }
Set the controller as the custom class for the first scene in the
Main.storyboard
file.将控制器设置为
Main.storyboard
文件中第一个场景的自定义类。
In the code, we load the settings using a SettingsService
class we will create later. When the settings are loaded for the device, we then call the routeToMainController
method, which routes the application to the main controller using the Main segue we created earlier.
在代码中,我们使用稍后将创建的SettingsService
类加载设置。 为设备加载设置后,我们将调用routeToMainController
方法,该方法使用我们先前创建的Main segue将应用程序路由到主控制器。
The next controller we will be creating will be the CoinsTableViewController
. This will be the controller that will be tied to the third scene which is the main scene.
我们将要创建的下一个控制器将是CoinsTableViewController
。 这将是与第三个场景(主要场景)相关联的控制器。
Create the CoinsTableViewController
and replace the contents with the following code:
创建CoinsTableViewController
并将内容替换为以下代码:
import UIKit import PusherSwift import NotificationBannerSwift
struct Coin { let name: String let rate: Float }
class CoinsTableViewController: UITableViewController {
var coins: [Coin] = []
override func viewDidLoad() { super.viewDidLoad() }
override func numberOfSections(in tableView: UITableView) -> Int { return 1 }
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return coins.count }
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let coin = coins[indexPath.row] let cell = tableView.dequeueReusableCell(withIdentifier: "coin", for: indexPath) as! CoinTableViewCell
cell.name.text = "1 \(coin.name) =" cell.amount.text = "$\(String(coin.rate))"
return cell } }
Set the controller as the custom class for the first scene in the
Main.storyboard
file.将控制器设置为
Main.storyboard
文件中第一个场景的自定义类。
Above we have defined the Coin
struct and it has a name
and rate
property. We have the controller which we define the coins
property as an array of Coin
s. We then have some boilerplate code that comes with creating a table view controller.
上面我们定义了Coin
结构,它具有name
和rate
属性。 我们有一个控制器,我们将coins
属性定义为Coin
的数组。 然后,我们有了创建表视图控制器时附带的一些样板代码。
The numberOfSections
method specifies how many sections the table will have. In the first tableView
method, we return the number of coins
available and in the second tableView
method, we define how we want each row to be handled.
numberOfSections
方法指定表将包含多少节。 在第一个tableView
方法中,我们返回可用coins
数量,在第二个tableView
方法中,我们定义如何处理每一行。
创建其他支持类 (Creating other supporting classes)
If you noticed in the code above, we referenced a CoinTableViewCell
as the class for each row in the last tableView
method. Let’s create that.
如果您在上面的代码中注意到,我们将CoinTableViewCell
引用为最后一个tableView
方法中每一行的类。 让我们来创建它。
Create a CoinTableViewCell
class and paste the following code into it:
创建一个CoinTableViewCell
类,并将以下代码粘贴到其中:
class CoinTableViewCell: UITableViewCell { @IBOutlet weak var name: UILabel! @IBOutlet weak var amount: UILabel! }
Open the Main.storyboard
file and set the class as the custom class for the prototype cell in the third scene of the Main.storyboard
file. When you have set the class, connect the @IBOutlet
s as specified in the cell class above.
打开Main.storyboard
文件,并在Main.storyboard
文件的第三个场景Main.storyboard
该类设置为原型单元的自定义类。 设置完类后,按照上面的单元格类中的指定连接@IBOutlet
。
The next class we need to create is the SettingsService
. This class will be responsible for updating and fetching the settings for the device.
我们需要创建的下一个类是SettingsService
。 此类将负责更新和获取设备的设置。
Create a new SettingsService
class and replace the contents with the following code:
创建一个新的SettingsService
类,并将内容替换为以下代码:
import Foundation import Alamofire import NotificationBannerSwift
class SettingsService { static let key = "CryptoAlat" static let shared = SettingsService()
var settings: Settings? { get { return self.getCachedSettings() } set(settings) { if let settings = settings { self.updateCachedSettings(settings) } } }
private init() {}
func loadSettings(completion: @escaping() -> Void) { fetchRemoteSettings { settings in guard let settings = settings else { return self.saveSettings(self.defaultSettings()) { _ in completion() } }
self.updateCachedSettings(settings) completion() } }
fileprivate func defaultSettings() -> Settings { return Settings( btc_min_notify: 0, btc_max_notify: 0, eth_min_notify: 0, eth_max_notify: 0 ) }
func saveSettings(_ settings: Settings, completion: @escaping(Bool) -> Void) { updateRemoteSettings(settings, completion: { saved in if saved { self.updateCachedSettings(settings) }
completion(saved) }) }
fileprivate func fetchRemoteSettings(completion: @escaping (Settings?) -> Void) { guard let deviceID = AppConstants.deviceIDFormatted else { return completion(nil) }
let url = "\(AppConstants.API_URL)?u=\(deviceID)" Alamofire.request(url).validate().responseJSON { resp in if let data = resp.data, resp.result.isSuccess { let decoder = JSONDecoder() if let settings = try? decoder.decode(Settings.self, from: data) { return completion(settings) } }
completion(nil) } }
fileprivate func updateRemoteSettings(_ settings: Settings, completion: @escaping(Bool) -> Void) { guard let deviceID = AppConstants.deviceIDFormatted else { return completion(false) }
let params = settings.toParams() let url = "\(AppConstants.API_URL)?u=\(deviceID)" Alamofire.request(url, method: .post, parameters: params).validate().responseJSON { resp in guard resp.result.isSuccess, let res = resp.result.value as? [String: String] else { return StatusBarNotificationBanner(title: "Failed to update settings.", style: .danger).show() }
completion((res["status"] == "success")) } }
fileprivate func updateCachedSettings(_ settings: Settings) { if let encodedSettings = try? JSONEncoder().encode(settings) { UserDefaults.standard.set(encodedSettings, forKey: SettingsService.key) } }
fileprivate func getCachedSettings() -> Settings? { let defaults = UserDefaults.standard if let data = defaults.object(forKey: SettingsService.key) as? Data { let decoder = JSONDecoder() if let decodedSettings = try? decoder.decode(Settings.self, from: data) { return decodedSettings } }
return nil } }
Above we have the SettingsService
. The first method loadSettings
loads the settings from the API and then saves it locally. If there is no setting remotely, it calls the defaultSettings
method and saves the response to the API.
上面有SettingsService
。 第一种方法loadSettings
从API加载设置,然后将其保存在本地。 如果远程没有设置,它将调用defaultSettings
方法并将响应保存到API。
The saveSettings
method saves the Settings
remotely using updateRemoteSettings
and then locally using updateCachedSettings
. The fetchRemoteSettings
gets the settings from the API and decodes the response using the Swift decodable API.
saveSettings
方法使用updateRemoteSettings
远程保存Settings
,然后使用updateCachedSettings
本地保存Settings
。 fetchRemoteSettings
从API获取设置,并使用Swift可解码API解码响应。
Next, let’s define the Settings
struct and have it extend Codable
. In the same file for the SettingsService
, add this above the SettingsService
class definition:
接下来,让我们定义Settings
结构并将其扩展Codable
。 在SettingsService
的同一文件中,将其添加到SettingsService
类定义上方:
struct Settings: Codable { var btc_min_notify: Int? var btc_max_notify: Int? var eth_min_notify: Int? var eth_max_notify: Int?
func toParams() -> Parameters { var params: Parameters = [:]
if let btcMin = btc_min_notify { params["btc_min_notify"] = btcMin } if let btcMax = btc_max_notify { params["btc_max_notify"] = btcMax } if let ethMin = eth_min_notify { params["eth_min_notify"] = ethMin } if let ethMax = eth_max_notify { params["eth_max_notify"] = ethMax }
return params } }
Above we have a simple Settings
struct that conforms to Codable
. We also have a toParams
method that converts the properties to a Parameters
type so we can use it with Alamofire when making requests.
上面我们有一个简单的Settings
结构,它符合Codable
。 我们还有一个toParams
方法,可将属性转换为Parameters
类型,以便在发出请求时可以将其与Alamofire一起使用。
One last class we need to create is AppConstants
. We will use this class to keep all the data that we expect to remain constant and unchanged throughout the lifetime of the application.
我们需要创建的最后一个类是AppConstants
。 我们将使用此类来保留我们期望在应用程序整个生命周期中保持不变的所有数据。
Create a AppConstants
file and paste the following code:
创建一个AppConstants
文件并粘贴以下代码:
import UIKit
struct AppConstants { static let API_URL = "http://127.0.0.1:8000/api/settings" static let deviceID = UIDevice.current.identifierForVendor?.uuidString static let deviceIDFormatted = AppConstants.deviceID?.replacingOccurrences(of: "-", with: "_").lowercased() static let PUSHER_INSTANCE_ID = "PUSHER_BEAMS_INSTANCE_ID" static let PUSHER_APP_KEY = "PUSHER_APP_KEY" static let PUSHER_APP_CLUSTER = "PUSHER_APP_CLUSTER" }
Replace the
PUSHER_*
keys with the values gotten from the Pusher Channels and Beams dashboard.将
PUSHER_*
键替换为从Pusher Channels and Beams仪表板获得的值。
更新设备设置 (Updating the settings for the device)
Now that we have defined the settings service, let’s update our controller so the user can set the minimum and maximum prices for each currency.
现在,我们已经定义了设置服务,让我们更新控制器,以便用户可以设置每种货币的最低和最高价格。
Open the CoinsTableViewController
class and add the following method:
打开CoinsTableViewController
类并添加以下方法:
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { let coin = coins[indexPath.row]
var minTextField: UITextField? var maxTextField: UITextField?
let title = "Manage \(coin.name) alerts" let message = "Notification will be sent to you when price exceeds or goes below minimum and maximum price. Set to zero to turn off notification."
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
alert.addTextField { textfield in minTextField = textfield textfield.placeholder = "Alert when price is below" }
alert.addTextField { textfield in maxTextField = textfield textfield.placeholder = "Alert when price is above" }
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil))
alert.addAction(UIAlertAction(title: "Save", style: .default, handler: { action in guard let minPrice = minTextField?.text, let maxPrice = maxTextField?.text else { return StatusBarNotificationBanner(title: "Invalid min or max price", style: .danger).show() }
var btcMin: Int?, btcMax: Int?, ethMin: Int?, ethMax: Int?
switch coin.name { case "BTC": btcMin = Int(minPrice) btcMax = Int(maxPrice) case "ETH": ethMin = Int(minPrice) ethMax = Int(maxPrice) default: return }
let settings = Settings( btc_min_notify: btcMin, btc_max_notify: btcMax, eth_min_notify: ethMin, eth_max_notify: ethMax )
SettingsService.shared.saveSettings(settings, completion: { saved in if saved { StatusBarNotificationBanner(title: "Saved successfully").show() } }) }))
present(alert, animated: true, completion: nil) }
The method above is automatically called when a row is selected. In this method, we display a UIAlertController
with two text fields for the minimum price and the maximum price. When the prices are submitted, the SettingsService
we created earlier takes care of updating the values both locally and remotely.
当选择一行时,上述方法会自动调用。 在此方法中,我们显示一个带有两个文本字段的UIAlertController
,分别是最低价格和最高价格。 提交价格后,我们之前创建的SettingsService
将负责在本地和远程更新值。
添加实时加密货币更新支持 (Adding realtime cryptocurrency update support)
Open the CoinsTableViewController
and add the pusher
property to the class as seen below:
打开CoinsTableViewController
并将pusher
属性添加到该类中,如下所示:
var pusher: Pusher!
Then replace the viewDidLoad
method with the following code:
然后,使用以下代码替换viewDidLoad
方法:
override func viewDidLoad() { super.viewDidLoad()
pusher = Pusher( key: AppConstants.PUSHER_APP_KEY, options: PusherClientOptions(host: .cluster(AppConstants.PUSHER_APP_CLUSTER)) )
let channel = pusher.subscribe("currency-update")
let _ = channel.bind(eventName: "currency.updated") { data in if let data = data as? [String: [String: [String: Float]]] { guard let payload = data["payload"] else { return }
self.coins = []
for (coin, deets) in payload { guard let currentPrice = deets["current"] else { return } self.coins.append(Coin(name: coin, rate: currentPrice)) }
Dispatch.main.async { self.tableView.reloadData() } } }
pusher.connect() }
In the code above, we are using the Pusher Swift SDK to subscribe to our currency-update
Pusher Channel. We then subscribe to the currency.updated
event on that channel. Whenever that event is triggered, we refresh the price of the cryptocurrency in realtime.
在上面的代码中,我们使用Pusher Swift SDK订阅了currency-update
Pusher Channel。 然后,我们在该频道上订阅currency.updated
事件。 每当触发该事件时,我们都会实时刷新加密货币的价格。
将推送通知添加到我们的iOS新应用程序 (Adding push notifications to our iOS new application)
To add push notification support, open the AppDelegate
class and replace the contents with the following:
要添加推送通知支持,请打开AppDelegate
类,并将内容替换为以下内容:
import UIKit import PushNotifications
@UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool { PushNotifications.shared.start(instanceId: AppConstants.PUSHER_INSTANCE_ID) PushNotifications.shared.registerForRemoteNotifications() return true }
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { PushNotifications.shared.registerDeviceToken(deviceToken) { if let deviceID = AppConstants.deviceIDFormatted { try? PushNotifications.shared.subscribe(interest: "\(deviceID)_eth_changed") try? PushNotifications.shared.subscribe(interest: "\(deviceID)_btc_changed") } } } }
In the class above, we use the Pusher Beams Swift SDK to register the device for push notifications. We then subscribe to the *_eth_changed
and *_btc_changed
interests, where *
is the device’s unique UUID.
在上面的类中,我们使用Pusher Beams Swift SDK注册用于推送通知的设备。 然后,我们订阅*_eth_changed
和*_btc_changed
兴趣,其中*
是设备的唯一UUID。
Now that we have completed the logic for the application, let’s enable push notifications on the application in Xcode.
现在,我们已经完成了应用程序的逻辑,让我们在Xcode中启用应用程序的推送通知。
In the project navigator, select your project, and click on the Capabilities tab. Enable Push Notifications by turning the switch ON.
在项目导航器中,选择您的项目,然后单击“ 功能”选项卡。 通过打开开关启用推送通知 。
This will create an entitlements file in the root of your project. With that, you have provisioned your application to fully receive push notifications.
这将在项目的根目录中创建一个权利文件。 这样,您就可以调配应用程序以完全接收推送通知。
允许我们的应用程序本地连接 (Allowing our application to connect locally)
If you are going to be testing the app’s backend using a local server, then there is one last thing we need to do. Open the info.plist
file and add an entry to the plist
file to allow connection to our local server:
如果您要使用本地服务器测试应用程序的后端,那么我们需要做的最后一件事。 打开info.plist
文件,然后在plist
文件中添加一个条目,以允许连接到我们的本地服务器:
That’s all. We can run our application. However, remember that to demo the push notifications, you will need an actual iOS device as simulators cannot receive push notifications. If you are using a physical device, you’ll need to expose your local API using Ngrok and then change the API_URL
in AppConstants
.
就这样。 我们可以运行我们的应用程序。 但是, 请记住,要演示推送通知,您将需要一台实际的iOS设备,因为模拟器无法接收推送通知。 如果您使用的是物理设备,则需要使用Ngrok公开本地API,然后在 AppConstants
更改API_URL
。
Anytime you want to update the currency prices, run the command below manually in your Laravel application:
每当您想更新货币价格时,请在Laravel应用程序中手动运行以下命令:
$ php artisan schedule:run
Here is a screen recording of the application in action:
这是正在运行的应用程序的屏幕录像:
结论 (Conclusion)
In this article, we have been able to see how easy it is to create a cryptocurrency alert website using Laravel, Swift, Pusher Channels and Pusher Beams. The source code to the application built in this article is available on GitHub.
在本文中,我们已经看到使用Laravel,Swift,Pusher Channels和Pusher Beams创建一个加密货币警报网站是多么容易。 GitHub上提供了本文构建的应用程序的源代码。
This article was first published on Pusher.
本文最初在Pusher上发表。
swift通知栏推送