在SwiftUI中使用Sign in with Apple

翻译原文:Sign in with Apple

学习如何在SwiftUI中实现Sign in with Apple,在iOS应用中给用户更多的隐私和控制。

Sign In with Apple 是iOS 13中的一个新特性,它会加快你的应用的注册和认证过程。

虽然苹果公司不断的强调Sign In with Apple的实现是简单的,但确实存在一些奇怪的地方要处理。在这个教程中,你不仅将学会如何恰当的实现Sign In with Apple,而且还教会你如何在SwiftUI中实现!

你将需要Xcode 11,一个付费的苹果开发这账号和iOS 13设备。

注意:你需要一台运行iOS 13的实体机。模拟器可能无法正常工作。

开始

请点击教程上方和下方的下载资源按钮来下载资源文件。因为你要运行在实体机和管理一些权限问题,所以你可以通过Project navigator点击新的Signing & Capabilities标签来设置你的team id和bundle id。如果你立即编译并运行app,你将看到一个正常的登录界面:

image1

注意:你可以先忽略Xcode显示的两个警告。你将通过接下来的教程解决它们。

添加功能

你的provisioning profile需要开启Sign In with Apple功能,因此立马加上它。在Project navigator点击项目,选择target里的SignInWithApple,然后点击Signing & Capabilities标签栏。最后点击+ Capability来添加Sign In with Apple功能。

如果你的应用也有相关的网站,你也应该添加Associated Domains功能。这个步骤在使用Sign In with Apple功能时是完全可选的,在这个教程中也不是必须的。如果你确认要使用一个相关的域名,请确认在域名栏中的值设置为webcredentials:+域名的形式。举个例子,类似于webcredentials:www.mydomain.com,在教程的后面部分你将学习如何在你的网站上做必要的改变。

添加注册按钮

苹果并没有给SwiftUI提供Sign In with Apple按钮,所以你需要自己包装一个。创建一个新的swift文件并命名为SignInWithApple.swift,然后复制这段代码。

import SwiftUI
import AuthenticationServices

// 1
final class SignInWithApple: UIViewRepresentable {
  // 2
  func makeUIView(context: Context) -> ASAuthorizationAppleIDButton {
    // 3
    return ASAuthorizationAppleIDButton()
  }
  
  // 4
  func updateUIView(_ uiView: ASAuthorizationAppleIDButton, context: Context) {
  }
}

解释下这段代码发生了什么:

  1. 当你需要包装一个UIVIew时需要子类化UIViewRepresentable
  2. makeUIView需要总是返回一个特定类型的UIView
  3. 因为你并不需要实施自定义,所以直接返回Sign In with Apple即可。
  4. 因为view的状态不会改变,所以空实现即可。

注意:如果你还没有尝试过SwiftUI,但是又想更深入的学习,请查阅这份教程

现在你可以将按钮添加到SwiftUI中,打开ContentView.swift并把以下代码添加到UserAndPassword界面下方:

SignInWithApple()
  .frame(width: 280, height: 60)

苹果的样式指导指出最小的尺寸为 280 * 60,所以确认要遵循。编译并运行你的应用你应该看到这个按钮

image3

处理按钮点击事件

就目前而言,我们点击按钮并无事发生。就在你设置按钮的frame下方,我们添加一个手势识别:

.onTapGesture(perform: showAppleLogin)

然后我们要在body属性后面实现这个showAppleLogin()方法:

private func showAppleLogin() {
  // 1
  let request = ASAuthorizationAppleIDProvider().createRequest()

  // 2
  request.requestedScopes = [.fullName, .email]

  // 3
  let controller = ASAuthorizationController(authorizationRequests: [request])    
}

解释下我们设置的东西:

  1. 所有的注册请求都需要一个ASAuthorizationAppleIDRequest
  2. 明确你需要知道的最终用户的数据类型。
  3. 生成一个控制器来展示注册对话框。

你应该仅仅在你真正需要用户数据时进行请求。苹果会为你生成一个用户ID。所以,如果你知识想获取用户的邮箱啦作为唯一标识,那么其实你并不是真正的需要获取用户数据 —— 所以在这种情况下你不要请求它。

代理ASAuthorizationControllerDelegate

当用户试图去鉴定时,苹果会调用一两个代理方法,因此,我们现在就实现它。打开SignInWithAppleDelegates.swift文件。你讲在这里实现当用户点击按钮后运行的代码。当然,你也可以在你想实现这段代码的地方实现,考虑到复用,将它转移到别的地方可能会是代码更干净。

目前我们会讲authorizationController(controller:didCompleteWithError:)方法留空,但是在真实产品中,我们需要处理这些错误。

当鉴定成功了,将会调起authorizationController(controller:didCompleteWithAuthorization:)协议方法。你可以在下载的样例代码中我们又两种情况需要处理。通过credential属性,我们可以检测出用户是通过Apple ID还是存储在iCloud中的密码进行的验证。

这个传递给代理方法的参数ASAuthorization属性包含了任何你想要获取的属性,包括邮箱或者名字。这个值的存在与否可以让我们识别这是一个新的认证还是一个已经存在的登录。

注意:苹果只会在第一次授权时提供详细的信息。

上述的注意点是需要牢记于心的!苹果假设你在获取到详细信息是会存储它而不是一次又一次请求获取。这就是你在处理Sign In with Apple时要处理的奇怪的地方之一。

试想一下这个情况,当一个用户在第一次注册时,你需要执行注册操作,苹果提供给你用户的邮箱和全名。然后,你尝试调用你服务器的注册代码,但是你的服务器不在线或者设备的网络链接失败了等等。

下一次用户登录的时候,苹果不会再提供详细的信息了,因为它希望你已经存储了它们。这就会进入运行第二种“用户已存在”的流程,如果你没存储,最终会导致失败。

处理注册流程

authorizationController(controller:didCompleteWithAuthorization:),在第一个case条件中,添加以下代码:

// 1
if let _ = appleIdCredential.email, let _ = appleIdCredential.fullName {
  // 2
  registerNewAccount(credential: appleIdCredential)
} else {
  // 3
  signInWithExistingAccount(credential: appleIdCredential)
}

在这段代码中:

  1. 如果你获取到详细的信息,你会知道这是一个新的注册。
  2. 一旦你获取到详细信息,就调用你的注册方法。
  3. 如果你没有获取到详细信息,就调用你的已存在账户的方法。

在扩展的顶部粘贴以下这段注册代码:

private func registerNewAccount(credential: ASAuthorizationAppleIDCredential) {
  // 1
  let userData = UserData(email: credential.email!,
                          name: credential.fullName!,
                          identifier: credential.user)

  // 2
  let keychain = UserDataKeychain()
  do {
    try keychain.store(userData)
  } catch {
    self.signInSucceeded(false)
  }

  // 3
  do {
    let success = try WebApi.Register(
      user: userData,
      identityToken: credential.identityToken,
      authorizationCode: credential.authorizationCode
    )
    self.signInSucceeded(success)
  } catch {
    self.signInSucceeded(false)
  }
}

这段代码中发生了这些事:

  1. 保存想要的详细信息并将苹果提供的user存入结构体中。
  2. 将详细信息存入到iCloud钥匙串中以便将来使用。
  3. 调用你服务器的服务并告诉调用者本次注册是否成功。

注意credential.user这个用法。这个属性包含了苹果给最终用户提供的唯一表示符。使用这个值——而不是邮箱后者登录名——当你在你的服务器上存储用户时。所提供的值完全匹配该用户所有跨平台的设备。此外,苹果也将该值提供给你的Team ID所拥有的所有的app。该用户所运行的任何一个app都会得到相同的ID,这也就意味着该用户在运行你的其他app时,你的服务器已近存储了它的信息,因此你不必在要求用户提供它!

你的服务器数据库很可能已经为用户存储了一些其他的标识符。简单的在你的用户类型表中添加新的一栏用来存放苹果提供的唯一标识。你的服务器端的代码将先检查这一栏是否匹配。如果没有找到,则转去你的已登录或注册流程,比如说使用邮箱地址或者用户名。

鉴于你的服务器是如何处理安全问题,你可能需要或者不需要传递credential.identityTokencredential.authorizationCode。OAuth的流程使用这两块数据。建立OAuth已超出了这份指南的范围。

注意:苹果提供了需要生成使用OAuth的公钥。本质上,它们提供给你一个JSON Web Key(JWK)。

为了确保存在钥匙串里,编辑UserDataKeychain.swift里的CredentialStorage更新account使其是你的app的bundle id拼接上其他的字符串。我喜欢在bundle id后拼接上.Detail。这么做主要是为了让account属性和bundle id不完全一致。所以呢存储的值只会用做你特定的目的。

处理已存在的账户

正如之前所描述的,当一个已存在的用户登录你的app,苹果是不提供email和全名的。将这个方法直接添加在SignInWithAppleDelegate.swift中注册方法的下方:

private func signInWithExistingAccount(credential: ASAuthorizationAppleIDCredential) {
  // You *should* have a fully registered account here.  If you get back an error
  // from your server that the account doesn't exist, you can look in the keychain 
  // for the credentials and rerun setup

  // if (WebAPI.login(credential.user, 
  //                  credential.identityToken,
  //                  credential.authorizationCode)) {
  //   ...
  // }

  self.signInSucceeded(true)
}

这个方法中的代码是非常app向的。如果你的服务器告诉你这个用户没有注册你将会接收到失败,你需要使用retrieve()来查询钥匙串。通过返回的UserData结构体,你将重新为该用户注册。

用户名和密码

在使用Sign In with Apple时,还有一种可能,就是用户选择那些已经存在iCloud钥匙串中的证书来登录。在authorizationController(controller:didCompleteWithAuthorization:)的第二个case条件居中添加以下代码:

signInWithUserAndPassword(credential: passwordCredential)

然后在signInWithExistingAccount(credential:)下实现对应的方法:

private func signInWithUserAndPassword(credential: ASPasswordCredential) {
  // if (WebAPI.login(credential.user, credential.password)) {
  //   ...
  // }
  self.signInSucceeded(true)
}

再一次,你的实现将会是非常app向的。但是,你将想通过用户名和密码来调用你服务器的登录。如果服务器无法找到这个用户,你将需要执行整个注册流程因为你没有为用户的邮箱和用户名存储在钥匙串中。

完成按钮点击处理

回到ContentView.swift中,你需要一个属性来放置你刚刚创建的代理。在类的顶部,添加以下这段代码:

@State var appleSignInDelegates: SignInWithAppleDelegates! = nil

@State是你告诉SwiftUI你的结构体内容是可变的可能发生更新的方式。所有的@State属性必须放置一个值,这就是为什么在这儿会看到奇怪的nil

现在,在同一个文件中,在showAppleLogin()中将controller的创建替换为以下代码:

// 1
appleSignInDelegates = SignInWithAppleDelegates() { success in
  if success {
    // update UI 
  } else {
    // show the user an error
  }
}

// 2
let controller = ASAuthorizationController(authorizationRequests: [request])
controller.delegate = appleSignInDelegates

// 3
controller.performRequests()

解释下发生了什么:

  1. 创建代理并赋值给该类的属性。
  2. 和之前一样创建ASAuthorizationController,但这次,我们使用自定义的代理类。
  3. 通过调用performRequests(),你讲要求iOS来展示Sign In with Apple的模态界面。

你的代理的回调的地方就是最终用户有没有成功授权你的app所需展示不同界面的地方。

自动登录

你已经实现了Sign In with Apple,但是用户必须明确的点击那个按钮才行。如果你把他们带入到登录界面,你需要判断他们是否已经通过Sign In with Apple。回到ContentView.swift,在.onAppear块中添加这行:

self.performExistingAccountSetupFlows()

注意:SwiftUI中的.onAppear()和UIKit中的viewDidAppear(_:)完全一致。

当界面出现的时候,你希望iOS检查和这个app相关的Apple ID和iCloud钥匙串的凭证。如果他们存在,你讲自动弹出Sign In with Apple对话框,这样的话用户就不必手动去点击按钮。因为手动点击和自动调用使用的是同一套代码,将showAppleLogin方法拆分为两段代码:

private func showAppleLogin() {
  let request = ASAuthorizationAppleIDProvider().createRequest()
  request.requestedScopes = [.fullName, .email]
  
  performSignIn(using: [request])
}

private func performSignIn(using requests: [ASAuthorizationRequest]) {
  appleSignInDelegates = SignInWithAppleDelegates() { success in
    if success {
      // update UI
    } else {
      // show the user an error
    }
  }

  let controller = ASAuthorizationController(authorizationRequests: requests)
  controller.delegate = appleSignInDelegates

  controller.performRequests()
}

在这里代码并没有变化,只是将代理的创建和展示放在单独的代码块中。

现在,我们来实现performExistingAccountSetupFlows()

private func performExistingAccountSetupFlows() {
  // 1
  #if !targetEnvironment(simulator)

  // 2
  let requests = [
    ASAuthorizationAppleIDProvider().createRequest(),
    ASAuthorizationPasswordProvider().createRequest()
  ]

  // 2
  performSignIn(using: requests)
  #endif
}

这里有几步操作:

  1. 如果你使用模拟器运行,那么什么都不做。如果你调用这个方法,模拟器会打印出错误。
  2. 要求苹果去请求Apple ID和iCloud钥匙串检查。
  3. 调用你已有的创建代码。

在第二步中,注意到你没有明确你想要获取的最终用户的详细信息。回忆之前的指南,你已经学到它只会仅有一次提供详细信息。由于这个流程是用来检查已存在的账户,我们并没有理由去请求requestedScopes属性。事实上,如果你在这里设置了,它也会忽略掉!

服务器凭证

如果你的app有一个特定的网站,你可以更进一步处理网站的凭证。如果你去看一眼UserAndPassword.swift文件,你会看到一处是调用SharedWebCredential(domain:)方法,目前只是传递一个空的字符串给构造方法。将字符串替换为你的网站的域名。

现在,登录你的网站并在网站的根目录创建一个文件夹叫做.wellknow,在文件夹中创建一个文件叫做apple-app-site-association,并粘贴下面的JSON:

{
    "webcredentials": {
        "apps": [ "ABCDEFGHIJ.com.raywenderlich.SignInWithApple" ]
    }
}

注意:确定这个文件名是没有扩展名的。

你需要把ABCDEFGHIJ替换为你苹果开发者账号的十个字符组成的Team ID。你可以在https://developer.apple.com/account网站的Membership栏下找到你的Team ID。你也需要将你使用的app都匹配上对应的bundle id。

通过这几个步骤,你将app中的登录详细信息和Safari中的登录的详细信息链接起来。现在在Safari上登录你的网站也可以使用Sign in with Apple了。

当用户手动使用用户名和密码时凭证会被保存,因此在下次需要使用时直接可用。

运行时检查

在你的app生命周期的任何一个时刻,用户都可以进入设置界面并将你的app的Sign In with Apple更改为不可用。基于用户该操作的执行,你想要检查用户是否还被允许登录。苹果推荐你运行这段代码:

let provider = ASAuthorizationAppleIDProvider()
provider.getCredentialState(forUserID: "currentUserIdentifier") { state, error in
  switch state {
  case .authorized:
    // Credentials are valid.
    break
  case .revoked:
    // Credential revoked, log them out
    break
  case .notFound:
    // Credentials not found, show login UI
    break
  }
}

苹果说明这个getCredentialState(forUserId:)的调用会非常的快。所以你需要在app被启动的时候以及任何你需要确保用户是否依然被授权的时候调用。而我建议你不要在app刚启动时运行除非这个操作时必须的。使用你的app难道真的需要一个登录的或者已注册的用户吗?应该仅当用户操作那些必须登录后的操作时才执行这段代码。事实上,这也是Human Interface Guidelines里面推荐的那样!

记住有许多用户会卸载刚下载的app,因为这个app在第一次启动时就要求他们进行注册。

取而代之,我们应该监听苹果提供的通知来知晓用户是否登出。简单的我们监听ASAuthorizationAppleIDProvider.credentialRevokedNotification通知并作出相应的操作。

The UIWindow

此时此刻,你已经完全实现了Sign In with Apple。祝贺!

如果你已经观看了关于Sign In with Apple的WWDC演讲或者阅读了其他的指南,你可能会注意到我们这丢失了一段。你没有实现ASAuthorizationControllerPresentationContextProviding代理方法来告诉iOS该使用哪个UIWindow。然而,技术上来说如果我们使用默认的UIWindow,我们什么都不要做,但是知道如何操作还是对我们有好处的。

如果你没有使用SwiftUI,那么将window属性从你的SceneDelegate总抓取出来并返回是相当简单的。但是在SwiftUI中,这却变得有些难度。

The Environment

SwiftUI中有一个新的概念叫做Environment,这是一你存储给很多SwiftUI界面使用的数据的地方。在某种程度上,你可以把它认为是一种依赖注入。

Environment创建

看一眼EnvironmentWindowKey.swift,你会看到那些需要在SwiftUI中存储值的代码。因为你去定义一个键传递给@Environment属性包装起来并存储该值是非常样板化的。并且我们注意到,如果一个class类型要被存储,它要被明确标记为weak引用来放置循环引用。

注意:这个environment代码是由WWDC实验室的一位苹果工程师提供的。

改变ContentView

让我们跳转到ContentView.swift并在ContentView的顶部添加另一份属性:

@Environment(\.window) var window: UIWindow?

iOS将会自动构建window属性,在environment中获取它的值。

performSignIn(using:)方法时,我们修改构造函数使其传入window属性:

appleSignInDelegates = SignInWithAppleDelegates(window: window) { success in

你也想要告诉ASAuthorizationController你想要用自己的类作为presentationContextProvider的代理,所以在设置controller.delegate后面紧跟这句代码:

controller.presentationContextProvider = appleSignInDelegates
更新代理

打开SignInWithAppleDelegate.swift来更改处理新的属性和构造函数。用下面的代码替换掉类定义里的,而不是那些用来对注册和代理方法扩展中的代码:

class SignInWithAppleDelegates: NSObject {
  private let signInSucceeded: (Bool) -> Void
  // 1
  private weak var window: UIWindow!

  // 2
  init(window: UIWindow?, onSignedIn: @escaping (Bool) -> Void) {
    // 3
    self.window = window
    self.signInSucceeded = onSignedIn
  }
}

就几步操作:

  1. 存储一个新的window的weak引用。
  2. 在初始化函数中添加UIwindow参数作为第一个参数。
  3. 将传递进来的值存储到属性中。

最后,实现一个新的代理:

extension SignInWithAppleDelegates: 
    ASAuthorizationControllerPresentationContextProviding {
  func presentationAnchor(for controller: ASAuthorizationController) 
      -> ASPresentationAnchor {
    return self.window
  }
}

这个代理就只需要实现一个方法,目的就是返回一个期待的window,没错,就是用来展示Sign In with Apple对话框的window。

更新界面

UIWindow放入environment只差那么一小步。打开SceneDelegate.swift并将下面这行代码:

window.rootViewController = UIHostingController(rootView: ContentView())

替换为这些代码:

// 1
let rootView = ContentView().environment(\.window, window)

// 2
window.rootViewController = UIHostingController(rootView: rootView)

这里只做了两小步:

  1. 你创建了ContentView并追加了恰当的window值。
  2. 你讲rootView变量传递给UIHostingController代替的原来的初始化方法。

这个environment方法返回的some view基本上来说是它先拿了你的ContentView,将你传入的值强行推入到那个view的environment中,然后再把这个ContentView返回给你。任何从ContentView中展现的SwiftUI的view现在将都会在持有environment中的值。

如果你在其他地方创建一个新的root view,那个root view就不会持有environment中的值,除非你也同样明确的传递给它。

登录无法滑动

对于sign In with Apple我们要记住的一个缺点是iOS所展示的界面无法滑动!对于大多数用户来说他们并不在意,但记住这一点很重要。作为我的app使用的网站的拥有者,举个例子,我有很多登录账号。不仅仅是app的登录,还有SQL数据库的登录,PHP管理员的登录,等等。

如果你有太多的登录账号,很有可能会是最终用户看不到他们所需要的账号。尝试确保将你app链接的网站只有影响app登录相关的账号。而不要把你所有的app都捆绑到一个域名下。

接下来...

你可以点击顶部或者底部的下载按钮来下载整个项目文件。

目前为止SignWithAppleDelegates.swift返回的是一个布尔值的成功,但是你可能更像使用一些类似于Swift 5的Result类型,这样的话,你不仅可以反回服务器给的数据,也可以返回自定义的错误类型表示失败。请观看我们的视频教程,What's new in Swift 5: Types如果你对Result不熟的话。

我们希望你喜欢这份指南,如果你有任何问题和评论,请加入我们的论坛进行讨论!

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

推荐阅读更多精彩内容

  • 前言笔者最近了解了iOS13 新增的功能之Sign In With Apple。会输出2篇文章,给大家分享一下。这...
    Lucky_Man阅读 2,882评论 0 3
  • 级别: ★☆☆☆☆标签:「iOS 13」「双重因子验证」「Sign In With Apple」作者: WYW审...
    QiShare阅读 5,479评论 3 22
  • 原创: 前行哲 iOS知识分享 今天 通过本文,你将了解到是否需要集成 Sign in with Apple 功...
    Leeson1989阅读 55,580评论 49 122
  • 木青同学阅读 258评论 0 0
  • 我想你了,有很多话想和你聊,我想你对我每天都说的情话,想你对我的好,对我的关心,对我的爱……想起了你的魅力,想起了...
    d5905e026569阅读 107评论 0 0