24 KiB
查看正在调试的 iPhone 的日志:
cd /Users/oscar/Downloads/brew-4.3.8/bin ./ios-deploy --bundle_id com.easyprompts.aigrammar --download=/Library/Caches/swiftybeaver.log --to ~/Downloads/ 对应目录下的 Library/Caches/swiftybeaver.log 既是
我有个Appstore IAP 的问题。用户A在 我们的app里面,订阅了付费服务,使用的是他自己的apple id; 然后他在另外一台iPhone上登陆这个 apple id,打开我们的应用之后,点击 Restore Purchase,这会触发appstore的回调操作吗? 我该如何保证这个信息可以同步到我们的服务端?
好的,明白了。那么现在我们在新的这台设备上,没有得到新的订阅信息。我在app里使用了日志库来记录信息,我应该怎么把这个日志文件读取出来?
需要一个菊花转动,与后台交互
我们继续工作。先回忆一下我们之前都做了哪些。我们编写了GrammarCheckView,TranslateView,WordsView和SettingsView。他们分别完成不同的功能。现在,我们需要一个统一的组建,它需要在客户端有点击事件,与服务端交互时弹出,使得整个页面不可点击和操作;等服务端结果出来之后,再刷新页面。这个组建通常是一个转动的齿轮。它需要在我们上面提到的每个View中用到,所以,我们需要一个比较好的封装。请你以 WordsView 页面为例,给出实现的方法。
付费页面
quest1
我们继续在APP中增加付费功能。在首页,也就是GrammarCheckView中,我们有个"Try for Free" 按钮,我们希望用户点击后,能弹出一个付费界面。这个页面是全屏的,并且会在其他页面上弹出,比如SettingsView。所以,我们希望它能够被多个地方引用。 这个页面的布局,最上面,左侧是一个VIP的图片标记,可以用Text来包装,背景是淡黄色,文字是VIP,金黄色。右侧是一个关闭按钮,点击后关闭付费页面。 下面是个表格,有四行三列。表头的三行,分别是Features、Free、Premium。下面是一些功能的对比。 在下面,是个VIP的商品列表,这里可能有三个或者两个商品,要展示的信息,是商品名称、付费金额,以及单价。它分为两列,左边的主要字体是商品名称,下面一行小字是付费金额;右边的主要字体是单价。
最下面是一个购买的按钮,居中显示,点击后拉起应用商店的支付。 按钮下面可以加一行小字,显示“Billed Yearly,Cancel Anytime.” 注意这个字会随着用户选择的商品变化而变化。
现在,我们可以先生成一下页面看看效果了。
quest2
单词页面
Q1
首先,我们希望把搜索框和右边的Cancel按钮放到一个组件中,让它看起来像个整体。为此,我们在它们的背景上,增加一个矩形框,它与整体的背景颜色相同,但会有比较细的边框,用于和背景区分;然后我们把文本框的底色设置跟背景色接近,Cancel按钮只显示白色文字即可; 其次,我们现在用HStack封装了List,List是我们所需要的动态填充的数据。但现在的页面比较难看,我们希望封装List的部分,跟背景色完全重合,这样,从视觉上看,会只有几个List的内容。 请你帮忙修改一下代码,然后我们观察一下效果。有问题可以问我。
SettingsView 页面
首先,我们使用了List来展示几个功能,但我不希望有header这一行,我希望能够把Section的内容直接衔接到页面顶端。这应该如何设置? 还是说我们可以更换其他组件来实现这个功能?
好的。我们继续来修改。 首先,我希望这个ScrollView能撑满整个页面,这样能保持背景颜色的一致性; 其次,我们需要对每个功能增加一些效果: "Upgrade to Premium" ,我们在文字的左侧需要增加一个向上的箭头图标,用户点击这段文本,弹出我们之前写好的 VIPPaymentView; "Feedback", 我们在文字的左侧同样加一个反馈的图标,用户点击后,调用appstore的反馈入口; "About",我们在文字的左侧也加一个图标,用户点击后,会弹出一个包含 Privacy Policy 和 User Terms 的页面; "Restore Pruchases",左侧增加一个向上的箭头图标,用户点击后,调用我们已经开发完成的 IapManager 中的 restorePurchases 方法。 现在,请你理解需求,并在上面的代码上完成修改。
Term of Use
我们的AIGrammar应用已经开发了一段时间了。为了准备把它上架到app store应用中,我们需要准备两个文案,其中一个是用户服务条款,也就是 Term of Use。现在我们的应用会上架到除中国大陆以外的市场,它主要会包含对英语文本的语法和拼写纠错,翻译(英文到中文的互相翻译),英文单词的词典功能等。并且它会提供免费的基础服务,以及付费订阅的高级服务。除此之外,我们还希望用户可以使用他们的google或者apple账户来注册AIGrammar,这样会更好的完善付费功能与历史数据保存等。基于以上的内容,你可以用英语帮我写一篇Term of Use吗?
好的,那能继续给我一个 Privacy Policy 吗?
NetworkManager
在 NetworkManager 的 checkGrammar 函数中,它的header是这样的: let headers: HTTPHeaders = createAuthorizationHeader()
现在我们需要在headers中增加两个参数,一个是 timezone, 它是一个字符串,是用户所在的时区;另一个是 secondsfromgmt ,它表示用户所在市区与格林威治时间的差。 请正确获取这两个参数,并把它拼接到headers中。 另外,考虑到我们可能有若干个接口都需要增加这两个参数,所以可以把他们封装成一个函数。
很好。你还记得我们统一封装的网络应答吧,它是这样的: struct APIResponse<T: Decodable>: Decodable { let ret: Int let message: String let data: T? } 现在我们有个问题。服务端的接口,它的data字段直接是一个json格式的数组,比如返回的是我们之前定义过的 GrammarRes 数组。 // 确保GrammarRes遵循Codable协议,以支持JSON解析 struct GrammarRes: Codable { var plain: String var type: String var reason: String var correction: [String] }
那么,我们应该调用 performRequest 时,应该怎么传递 completion 部分?
我们根据之前编写的 NetWorkManager 类的代码,在其中增加了一个函数,它用来处理IAP的验证,代码如下:
// 查询VIP订单,属于 NetWorkManager 类。
func IapVerify(receiptData: Data, appAccountToken: UUID?, productId: String, transactionId: UInt64, completion: @escaping (Result<IAPVerifyRsp, Error>) -> Void) {
let url = globalEnvironment.iapVerifyURL
let parameters: [String: Any] = ["transid":transactionId, "appaccounttoken": appAccountToken ?? "", "productid":productId, "receiptData":receiptData]
let headers: HTTPHeaders = createAuthorizationHeader()
performRequest(
endpoint: url,
parameters: parameters,
method: .post,
encoding: URLEncoding.httpBody,
headers: headers, // 示例:使用JWT token
completion: { (result: Result<IAPVerifyRsp, Error>) in
switch result {
case .success(let rsp):
logger.info("verify succ: \(rsp.ret)")
completion(.success(rsp))
case .failure(let error):
logger.error("Error: \(error.localizedDescription)")
completion(.failure(error))
}
}
)
}
现在,我们需要在 IapManager中完成 validateReceipt 函数,它功能是调用上面的 IapVerify,并且获得其返回,根据返回结果,来控制一些界面的行为,比如弹出成功页面等。现在,请你完成代码。
现在我们调整一下它的输出。如果服务器端的http响应码是200,那么它正确处理的数据格式如下: { "ret": 0, "message": "success", "data": { "id": 10002, "userid": "", "username": "", "vip": 0 } } 如果服务器端处理出错了,它会返回这样的格式: { "ret": 500, "message": "bad request", "data": nil } 现在我们需要按照这个格式处理数据。处理逻辑是,如果服务器成功响应(即返回了200状态码),那么先解析ret字段,如果ret为0,那么继续匹配我们之前定义的结构体,比如: struct VIPStatusResponse: Codable { var isvip: Int } 如果ret不为0,需要按照出错来处理,并不需要解析data字段。请调整你的代码。
class InitApp { static let shared = InitApp()
private let userDefaults = UserDefaults.standard
private let deviceIDKey = "DeviceID"
init() {
initializeApp()
}
private func initializeApp() {
// do something
print("init")
}
}
这是一个用swift5编写的类。请问它的 shared 变量是什么作用? 如果我想在app启动时就进行这个类的初始化,那应该怎么调用?
我们创建 user.go 文件,它完成我们的用户相关的功能,并被 main.go 中的echo入口函数调用。第一个功能是当用户启动app时,发送的查询请求 : 1,函数名是 queryUserHandler , 他读取 echo框架中的 请求参数,分别是 deviceid 和 userid,类型为string; 2,我们使用 deviceid 查询 user表,以deviceid匹配 user.DeviceID,如果查询到记录,则返回 ID,UserID,UserName; 如果查询不到,则说明该用户不存在,需要插入一条记录,user.DeviceID=deviceid;返回 ID; 3,我们用查询到的ID,来查询 vip表,如果查询到,则返回 vip = vip.IsVIP, 如果查询不到,则 vip=0; 4,我们定义个json结构体,作为函数的整体返回,格式为 {"userid" : ID, "username": UserName, "vip": vip} 现在,请完成这部分的代码。
我们在使用swift5开发iOS应用。我们打算创建一个swift文件,它里面有一个initAPP类,用于执行在app启动时的各种初始化动作。包括: 1,获取 DeviceID。如果本地的 UserDefaults 中没有数据,那么需要调用 TrustDecision 来获取DeviceID;如果 UserDefaults 中有,则直接使用; 2,获取用户的VIP状态,它需要发起一个网络访问,我们使用 Alamofire 网络框架包,来发起查询;获取到的 VIP 状态,保存为一个内部的变量即可,这样我们每次启动都会查询一次;
现在,请你帮我写出 initAPP 的代码框架,并且完成初步的工作。
我们需要在里面增加一个函数,它的功能是从服务器端获取用户的付费状态: 1,后端服务器的地址是 http://localhost:1080/user/queryvip 使用POST提交,参数使用 x-www-form-urlencoded 编码。 2,提交的参数,user 是个字符串,它是用户注册的用户名;deviceID是设备ID,它是一个字符串。 3,服务器返回的结果,如果 http code 为200,表示正常返回,body内容为 {"isvip":"%d"}, 对应的%d就是我们要的结果;其他http code 表示为异常; 4,使用之前写的基于 Alamofire 和 SwiftJWT 的代码,完成服务器访问,注意要处理正常和异常的返回情况,并且我们需要设定服务器访问的超时时间为10s; 5,我们在一个 initAPP 类的函数 queryVIP 中调用上面的函数,如果正确返回,则设置 initAPP 类中的 变量 vipStatus;它是一个bool类型,对应上面body中的 isvip 的值。 请结合已完成的NetworkManager代码,实现上述功能。
好的,我们调整一下当前的实现: 1,我们不需要使用echo框架,因为在我们的主程序中已经包含了,NewUserBenefits 只需要完成特定功能即可; 2,我们需要修改返回内容,明确区分用户是否有权益、权益已正常消耗,以及其他的错误,为此,我们除了error,还要增加一个返回值,可以是 int,返回 0 表示成功,其他值表示失败; 3,我们的输入参数,除了userID, timeZone之外,还需要增加一个 secondsFromGMT, 它是一个 int类型的值,表示为 传入的时区,与格林威治时间的偏移; 4,我们增加一下代码逻辑,time.LoadLocation(timeZone) 这里增加一下错误处理,如果失败了(比如传入的timezone无法匹配等),那么 我们用 secondsFromGMT 来计算传入的时区; 5,我们需要增加一个 query 函数,它有一个输入参数 userID,用于查询数据库中所有与之相关的key,并以json格式的数据进行返回; 6,考虑到 redis 的处理性能问题,我们希望把当前的创建连接方法,改为有最大连接限制的连接池,每次查询时,从连接池中获取; 请你按照上面的修改意见,继续完善代码。
我在使用apple的 IAP功能,接入storekit2,在购买接口时这样传入 appAccountToken :
let uuid = Product.PurchaseOption.appAccountToken(UUID.init(uuidString: "xxxxx")!)
let result = try await product.purchase(options: [uuid])
但在运行时报错了,显示:Fatal error: Unexpectedly found nil while unwrapping an Optional value 。这是为什么?如何才能正确的传入 appAccountToken ?以及如何设置appAccountToken,才能更好的完成交易?
我在打印 StoreKit2 返回的交易结果时,期望能把交易的 jsonRepresentation 整体打印出来,但是用下面的方式: if let receiptData = try? transaction.jsonRepresentation { print("Transaction Receipt: (receiptData)") }
它的输出只是 Transaction Receipt: 769 bytes 。如何才能详细打印出 jsonRepresentation
很棒!我已经引入了 TrustDecision,使用它来获取 DeviceID的相关代码是这样的:
import TrustDecision
var options = String : NSObject
let responseCallback: ([String : Any])-> Void = { response in
// Response in sub-thread, do something with the response
// Get DeviceId
let deviceId = response["device_id"]
// Get DeviceRiskLabel
let deviceRisk = response["device_risk_label"]
// Get DeviceDetail
let deviceDetail = response["device_detail"]
}
options["callback"] = unsafeBitCast(responseCallback as @convention(block) ([String : Any]) -> Void, to: AnyObject.self) as? NSObject
let manager = TDMobRiskManager.sharedManager()
manager?.pointee.initWithOptions(options)
应用到我们的App中,我希望在App启动时,就调用以上的相关代码,来获取DeviceID,然后把这个DeviceID存储在一个公共的变量中,使得每个功能模块,都能很方便的使用它,但不能修改它。请帮我实现以上代码。
作为提醒,我们回顾一下当前的代码结构: 一个AIGrammarApp view,用来引用 AllTabView; AllTabView实现了底部导航栏,它有四个功能模块:GrammarCheckView、WordsView、TranslateView、SettingsView
我们给之前写的代码加一些网络请求。当用户点击“赞”,或者“踩”的按钮时,我们发起一个Http请求,它使用POST方法,发送的参数有: product,值为“trans”,表示为翻译模块; input 和 output,分别为该点击对应的原文和译文; res,当动作为点赞时,为“good”,点踩时,为“bad”。 发送之后可以不用等待应答,就返回到主应用中。这个请求的地址是 http://localhost:1080/grammar/feedback,使用x-www-form-urlencoded提交参数。 请注意,我们希望使用 Alamofire 网络框架来实现它,并且希望请求的地址是写在配置文件中,而不是在代码里。这意味着我们可以在之前定义的 GlobalEnvironment 中加入这个变量。
很好。现在我们要继续在这个请求上加上鉴权。 我们在早些时候,写完了生成DeviceID,并把它存入全局变量的工作。我们为AF.request中加入jwt鉴权。参与鉴权的信息: jwt token secret 是一个变量,我们可以放在GlobalEnvironment中,它的值是your_jwt_secret jwt采用HS256加密,payload 是: { "deviceID": "%s", "gid": "%s", "exp1": %d } 其中 deviceID 是我们之前取到的值,gid 是用户ID,目前保留为空; exp1是时间戳字段,我们设置为当前的时间戳加一天。
现在,我们需要把这个鉴权过程加上去,并设置合适的http header,重新发起请求。
SubmitTextEditor 的功能需求是,把用户输入的文本 inputText,提交到后端的服务器上,等服务器返回了结果之后,解析结果,并显示到界面上来。为此,我们需要: 1,后端服务器的地址是 http://localhost:1080/grammar/translate 使用POST提交,参数使用 x-www-form-urlencoded 编码。 2,提交的参数,input 的值是 $inputText,也就是用户输入的内容; lang 的值我们先传入字符串 ‘chs’,后面我们将处理它。 3,服务器返回的结果,如果 http code 为200,表示正常返回,body内容为 {"translation":"%s"}, 对应的%s就是我们要的结果output;其他http code 表示为异常; 4,使用之前写的基于 Alamofire 和 SwiftJWT 的代码,完成服务器访问,注意要处理正常和异常的返回情况,并且我们需要设定服务器访问的超时时间为10s; 5,当服务器正常返回时,我们调用addTranslation,此时需要把返回结果output传入;如果是异常返回,弹出toast提示用户“network error,please retry later.” 请结合刚才的代码,实现上述功能。
TextField("word", text: $searchText, onCommit 这里,我们对 onCommit事件加上与服务器端的交互: 1,我们读取 searchText 的值,把它作为参数提交到服务端; 2,后端服务器的地址是 http://localhost:1080/grammar/words 使用POST提交,参数使用 x-www-form-urlencoded 编码。 3,提交的参数,input 的值是 $searchText,也就是用户输入的内容; lang 的值我们先传入字符串 ‘eng’,后面我们将处理它。 4,服务器返回的结果,如果 http code 为200,表示正常返回,body内容为 {"word":"$word", "explain":["$exp1", "exp2", ...], "phrase": ["$p1", "$p2", "p3", ...], "sync": ["$s1", "s2", ...]} ,我们需要解析结果,并把 explain, phrase, sync 分别赋值给 wordDefinitions, commonPhrases, synonyms。请注意,我们可能需要修改这几个变量的声明方式。 5,当 http code 为200 以外的值时,表示为结果异常,需要弹出toast提示用户“network error,please retry later.” 6,我们的网络交互部分,可以在刚刚完成的 NetworkManager 中添加;这样 wordsview中只需要调用接口即可,保持代码简洁。 现在,请你理解上面的需求,并输出相关的代码。
let wordDefinitions = ["Definition 1", "Definition 2", "Definition 3", "Definition 4", "Definition 5"]
let commonPhrases = ["Phrase 1", "Phrase 2", "Phrase 3"]
let synonyms = ["Synonym 1", "Synonym 2", "Synonym 3"]
很好。我们现在优化一下globalEnvironment,它现在是这样写的:
import Foundation import SwiftUI
class GlobalConfig : ObservableObject{ @Published var backgroundColor : UInt = 0xFFE4E1
}
class GlobalEnvironment: ObservableObject { @Published var deviceID: String = ""
var jwtSecret: String = "your_jwt_secret"
// 请求地址
var baseHost: String = "http://localhost:1080"
var feedbackURL: String = "http://192.168.2.2:1080/grammar/feedback"
var translateURL: String = "http://192.168.2.2:1080/grammar/translate"
var dictURL: String = "http://192.168.2.2:1080/grammar/words"
var grammarURL: String = "http://192.168.2.2:1080/grammar/grammar"
}
// 全局实例 let globalEnvironment = GlobalEnvironment()
我们可能有多个URL地址,他们的基础路径可能是一样的,比如域名,端口等这些;所以我们不希望每个地址变量都写一遍,而是有一个baseHost定义公共的部分,后续的URL都以此为基础,加上自己的相对路径。现在,请你帮我优化一下上面的写法。
我们要在 InputView 中的 Button("Check") 事件里,增加对服务器端的访问。整个过程是: 1,我们读取textInput的值,把它作为参数提交到服务端; 2,后端服务器的地址是 http://localhost:1080/grammar/grammar 使用POST提交,参数使用 x-www-form-urlencoded 编码。 3,提交的参数,input 的值是 $textInput,也就是用户输入的内容; lang 的值我们先传入字符串 ‘eng’,后面我们将处理它。 4,服务器返回的结果,如果 http code 为200,表示正常返回,body内容格式为 [{"plain":"%s", "type":"%s", "reason":"%s", correction":["%s", "%s"]}, {"plain":"%s", "type":"%s", "reason":"%s", "correction":["%s", "%s"]}, ……] ,这是一个json数组。我们需要解析它,并赋值给 GrammarCheckView 中的 $results。它是一个数组,格式为GrammarRes,请注意,为了适配解析,我们可能要对 GrammarRes 的定义做调整。 5,当我们处理完服务器交互之后,可以继续 Button("Check") 里的几个现有的设置,这样,页面会切换到 ResultView,显示结果。 6,当 http code 为200 以外的值时,表示为结果异常,需要弹出toast提示用户“network error,please retry later.” 7,我们的网络交互部分,可以在刚刚完成的 NetworkManager 中添加;这样 wordsview中只需要调用接口即可,保持代码简洁。 现在,请你理解上面的需求,并输出相关的代码。
textContent 在 ResultView 中,我们有个函数 func styledText() -> Text ,它的作用是检查结果中的字符串,并且拼接出一个完整的输出。现在我们调整一下它的实现: 1,我们的 outstr 默认为 textContent,这意味着我们有完整的 textInput ; 2,对results的每一次遍历,我们可以用 res.plain 去匹配 outstr,如果匹配的到,那么用 getColoredText 转换之后的字符串,替换被匹配到的 outstr 中的子串; 3,这样遍历完毕之后,我们的到的 outstr,是完整的 textInput,并在每个results的输出部分,被做了标注,标注的方式是 getColoredText。 4,请注意,res.plain 去匹配 outstr 的时候,我们认为每个 res.plain 出现的顺序,与其在 textInput 中出现的顺序是匹配的;这意味着,如果 results 的第一个 res.plain 被匹配到了,那么第二个 res.plain 去匹配时,无需从 outstr的开头匹配,只需从上一次匹配到的位置之后开始。
它报错了。我们换个实现: 1,首先,我们遍历 results ,使用 res.plain 去分隔 textContent,使它成为一个数组;这个数组不被 res.plain 命中的子串,使用 textContent 中的原文; 2,被 res.plain 命中的子串,我们使用 getColoredText 转换之后的字符串,来替代它,存储到数组中; 3,最后,我们按顺序把整个数组拼接起来。 好了,重新实现一遍。
我们有一个字符串 textContent,和一个由若干个textContent的子串组成的数组 results。这些子串在数组中的顺序,与其在textContent中出现的顺序一致。现在,我们的需求是,把textContent中,出现在results 中的子串,换成 getColoredText(str: res.plain, type: res.type, index: index) 的结果;而其他不匹配的子串保持不变。请使用swift5,写出你的代码。
举个例子: textContent = "This is a demo text with more complex grammar checking algorithm of the pro version. This app have been designed to help you to write correctly and appear more professional. I work really hard to make this app as god as possible. If you are happy with the results, please consider supporting the app by subscribing to the PRO plan. The text writen here is to show you how much the app is capable of with the pro version. is this something you would like to use?"
results = [{"plain":"This app have", "type":"grammar", "reason":"verb agreement error", "correction":["This app has"]}, {"plain":"god", "type":"spell", "reason":"spelling error", "correction":["good"]}, {"plain":"The text writen here", "type":"spell", "reason":"spelling error", "correction":["The text written here"]}, {"plain":"is this something", "type":"grammar", "reason":"start of sentence punctuation error", "correction":["Is this something"]}]