licc

心有猛虎 细嗅蔷薇

0%

背景

在iOS14.5以后Apple启用ATT(AppTransparencyTracking)。ATT 意味着,如果你的应用程序收集有关最终用户的数据并与其他公司共享以跨应用程序和网站进行跟踪(IDFA),则必须使用 ATT 同意提示并在发布商和广告商应用程序中获得用户同意。如果不跟踪,则无需显示提示。

ATT政策正在加速移动广告行业跃变,用于广告追踪的 IDFA 或将逐渐淡出历史舞台,至此iOS进入后IDFA时代。

Apple给出的解决方案正是SKAdNetWork(SKAN),但是SKAN并不是iOS14.5之后才发布的,SKAN 1.0是Apple于2018年推出的API,在iOS14.5推出ATT后推出2.0。后续更新到了3.0版本,在iOS16.1后推出了全新的SKAN 4.0版本。

大规模的使用是在SKAN 3.0版本,我们也是在3.0版本开始接入。下面先简单介绍下SKAN归因的原理。

SKAN3.0归因流程

下面是归因的流程图,看不懂没关系,先搞明白几个概念。

1.Conversion value (CV)

上图有2个方法registerAppforAdNetworkattribution()和updateConversionValue(:_)

updateConversionValue(:_)提到了ConversionValue就是归因的重点。

你可以理解成Apple最后回传给广告主的就是这个CV值,取值范围在0-63之间。

为什么是0-63呢,因为这个CV是一个6bit的值,在二进制中取值范围中就是0-63之间。

SKAN的重点就是合理利用这个64个数

CV值是可以更新的,调用updateConversionValue即可更新。但是新的值必须比旧的大,否则不生效。

2.计时器挑战

Apple在SKAN中设计了一个计时器功能,App首次安装时候首先调用registerAppforAdNetworkattribution()

开发者可以在24小时的循环周期内反复调用updateConversionValue: 去更新转化数值。

调用此方法有两个目的:

产生一个安装通知,是一个加密签名的数据包,用于验证是否来自广告

应用提供或更新一个转化数值

每次更新CV值成功,计时器延迟24个小时。

如果24小时内CV都没更新,则确定了最终的CV值,最终值确定后,又会在24小时之内把CV值回传给广告平台。

也就是说安装激活转化值在最快会在24-48小时内回传给广告主。

如何理解?安装的CV值都是0,用户安装24小时都没有触发CV值更新,24小时后计时器停止,最终CV值就是0,而Apple又会在24小时内postback,也就是再加0-24小时之间的时间,那就是24-48小时。

这在用习惯IDFA归因看来简直效率低下,之前IDFA归因广告投放后用户安装后立刻就能知道,而使用SKAN最快也要24-48小时之后才知道安装数(还不包括后续CV更新,只看激活数)

举个极端例子,如果CV值对应的转换事件设置不恰当,用户从0一直更新到63,那么你要在60多天后才拿到这个用户最终的CV值。

3.隐私阈值

看了上面的计时器觉得SKAN已经很难用了吧,别慌,还有一个更坏的消息。CV值最终是否传递给广告主还要看用户的行为是否满足Apple的隐私阈值。

这是阈值具体是什么呢?抱歉,Apple没说,一切都是黑盒。

(在我们接入过程中有广告主试了下,得出的大概结论是每个广告系列平均每天安装120个以上才符合,但是很快Apple又调整了隐私阈值)

如果不符合阈值呢,那Apple会回传给你一个Null。

而经过我们测试,新开的广告系列或者效果一般的广告,不符合隐私阈值拿到CV值为null的占比高达90%

upload successful

SKAN技巧及坑

了解了SKAN的原理,那么说一下SKNA的优缺点。

Apple说优点有很多,我都放下面这个图里了,在隐私越来越严格的背景下,可以预见SKAN在很久一段时间都会是常态。

缺点呢,有很多,最重要的一条就是时效性太差太差。

这对整个团队也是一种考验,作为研发人员,你不但要深入了解SKNA原理,还需要了解各个广告平台,MMP平台的SKNA解决方案(每个平台方案都不同),还要让投放、产品、市场都了解和使用SKAN,合理利用64个CV值的映射,这无疑也是个挑战。

1.使用MMP平台

广告归因平台(Mobile Measurement Partner,简称MMP),SKAN归因特别复杂,所以一般都使用MMP平台来集成,MMP平台去对接各大广告平台,和各大广告平台的SKAN方案做桥接可以做到只设置一个CV映射表对应所有广告平台。

拿我们使用的AppsFlyer平台举例,他们的CV映射方案如下:

具体来讲有分为几个大的维度

更多信息可以去对应的MMP平台查看。

下面是整个MMP平台和广告平台归因流程。

2.合理设置CV值

在MMP平台,可以设置CV映射表,一般就是应用内事件、订阅收入等。

那么如何理解CV值的,拿下图来说,我们统计了

1.收入价值,分了4个范围(对接FB要求必须4个范围,实际可能用不到这么多)

2.应用内事件subs_success

3.归因窗口期

我们会在subs_success事件上报一个事件价值(价值是取平均值,比如你上报的价值落在0-20之间,MMP平台统一统计为10美金),那么CV值=6就代表:用户在激活后0-12小时之内订阅了,产生了subs_success事件,事件价值10美金。

那么7就代表用户在激活后0-12小时之内订阅了,产生了subs_success事件,产生了(20+40)/ 2 = 30美金的价值。

3.各广告平台事件映射

MMP和各个广告平台对接方式都不一样,这里不再展开讲,需要注意的就是CV映射值的调整,在部分平台同步生效需要36-48小时不等,更改CV映射表期间应该暂停广告并且新旧映射表交替时候数据会有部分混乱。

具体看MMP平台文档。

另外还配到一些映射不上,无法拉取成本等等问题,有些是广告平台的问题,需要督促MMP平台找广告平台解决。

这里补充下,MMP平台和广告平台CV值怎么同步的问题。

重点就是你在Google投放的广告,Apple会把最终CV值给Google,比如CV=6,Google要么会从MMP拉取一份你配置的CV映射表,要么回去MMP问CV=6代表什么。同时把CV=6发送给MMP,MMP再在面板上展示数据。

还有提醒一下,MMP平台的SKAN策略和广告平台的会不一致,拿Google来说,Google后台可以Apple给的CV值,但是Google还有自归因逻辑,存在null的情况下GG会自归因判断是否激活。CV值和MMP最终对不上。

那么有的同学就发现这里存在漏洞,既然是Apple给广告平台,广告平台再给MMP,广告平台会不会篡改CV值呢。

因为有些平台结算是按激活量的。这个就是下面要提的交叉验证了。

4.MMP交叉验证

Apple在iOS15推出了新功能,可以在发送CV值给广告平台时候,同步发送给另外一个地址。这样就杜绝了广告平台欺诈的情况。

4.成绩

其实对SKAN的介绍要想说透彻必须单开一篇文章来讲了。

下面说下我们的成绩,截止到SKAN4.0之前,SKAN的归因数据肯定不真实的,为什么?因为隐私阈值的存在,数据相当于抽样。

但是每个平台都抽样,只看绝对值还是能对比那个广告渠道转换和收益好的。

期间也经历了MMP平台CV值混乱bug等等,但是总体来说SKAN是目前最优解决方案。

目前我们一个订阅上报10美金价值,数据去除隐私阈值的损耗,看整体趋势也能和Apple后台数据对得上。

除了SKAN,还可以使用Apple官方的ASM(App Store Search Marketing )又名 ASA(Apple Search Ads)。ASM并不使用SKAN,而是传统的归因(果然好东西还是留给自己平台)。

关于更多SKAN4.0的信息,我会单独开一篇来讲。

上篇详见:iOS XCTest实战—解决国际化开发测试痛点(上)

4. StoreKit Configuration File(无订阅需求可略过)

因为我们是海外订阅类App,这个测试脚本最初的目的就是为了测试订阅页以及整个购买流程,因此要对主要国家的货币和价格进行测试。
但是在Xcode12中,模拟器并不能读取线上的SKProuduct信息(Xcode13已经修复)。而真机测试也只能每次手动切换沙盒账户来切换国际和货币币种。
如下图所示,订阅页需要适配一些超长的货币(一般坦桑尼亚货币最长)。

通过StoreKit可以很方便的解决这个问题。
Apple 在 Xcode12 中引入了本地 StoreKit 测试,无需连接到 App Store 服务器即可测试不同的 IAP 场景。
更多信息详见:《Setting Up StoreKit Testing in Xcode》

i.创建StoreKit Configuration File

创建方式很简单,在新建文件中找到StoreKit Configuration File

点击加号,新增一个自动续期SKU,当然也可以用来测试消耗类内购。

按着真实的线上SKU进行配置,还能配置推介促销优惠、促销优惠、家庭共享等功能。价格这里只需要填写金额

在Schemes设置中,添加刚刚配置的StoreKit Configuration

重新运行项目,就能在获取SKProductsRequestDelegateproductsRequest方法中拿到模拟的SKU了,金额默认是美元。

而更改货币也很方便,在项目中选中StoreKit Configuration文件,在Xcode中的Editor—>Default Storefront中进行选择相应的币种。

只需要在StoreKit Configuration中更改价格就行了,会自动读取设置的Storefront币种。

更改Storefront本质上就是更改SKProductpriceLocale,注意最终价格的呈现方式要用系统提供的NumberFormatter来计算

let formatter = NumberFormatter.init()
formatter.formatterBehavior = NumberFormatter.Behavior.behavior10_4
formatter.numberStyle = NumberFormatter.Style.currency
formatter.locale = product.priceLocale
self.price = formatter.string(from: product.price) ?? ""

ii. 在XCTest中使用StoreKit Configuration File

刚刚的配置只是在SchemesRun环境下配置了StoreKit Configuration

而在我们需要的Test环境下并没有配置入口

因此需要用代码来解决

首先找到StoreKit Configuration,把文件共享给UITests Target

然后在需要用到StoreKit Configuration的test方法中,根据新建name新建SKTestSession
更多信息请参考:《SKTestSession | Apple Developer Documentation》

注意:想要在自动化测试中使用StoreKit Configuration,需要用到SKTestSession,只有iOS14以上才支持。

func testSubscribePage() throws {
if #available(iOS 14.0, *) {
let session = try? SKTestSession.init(configurationFileNamed: "Configuration")
session?.disableDialogs = true
session?.clearTransactions()
} else {
....
}
.....

5. xcodebuild

执行完上述步骤,已经可以对单个模拟器或真机执行Test Plans。如果想一次执行多个机型,就需要用到xcodebuild命令了。
熟悉Jenkins打包的同学应该对xcodebuild很熟悉,其实我们每次在Xcode进行的RunBuildArchive等操作本质上都是执行相应的xcodebuild命令。
使用xcodebuild命令运行Test Plans命令如下

//使用了pod的话就需要执行xxx.xcworkspace
//scheme 选择UITest
xcodebuild test -workspace UITestDemo.xcworkspace -scheme UITestDemoUITests -destination 'platform=iOS Simulator,name=iPhone 12 Pro Max,OS=14.5'

i. 同时运行多个模拟器和真机

一次运行多个模拟器也是可以的,还可以真机模拟器一起运行,支持不同iOS版本的模拟器同时运行。

xcodebuild test -workspace UITestDemo.xcworkspace -scheme UITestDemoUITests 
-destination 'platform=iOS Simulator,name=iPhone 12 Pro Max,OS=14.5'
-destination 'platform=iOS Simulator,name=iPhone 12,OS=14.5'
-destination 'platform=iOS Simulator,name=iPhone 8 Plus,OS=14.5'
-destination 'platform=iOS Simulator,name=iPhone 8,OS=14.5'
-destination 'platform=iOS Simulator,name=iPhone SE (2nd generation),OS=14.5'
-destination 'platform=iOS,name=caniPhone'

注意模拟器和真机的Name必须准确,查看所有可执行的模拟器和真机可以使用xcrun xctrace list devices

ii. 指定derivedDataPath

正常运行Test Plans,运行结果只能在Xcode中查看并且路径很深,也可以使用derivedDataPath指定结果的输出路径

xcodebuild test -workspace UITestDemo.xcworkspace -scheme UITestDemoUITests -derivedDataPath '/Users/cc/Desktop/outData'
-destination 'platform=iOS Simulator,name=iPhone 12 Pro Max,OS=14.5'
-destination 'platform=iOS Simulator,name=iPhone 12,OS=14.5'
-destination 'platform=iOS Simulator,name=iPhone 8 Plus,OS=14.5'
-destination 'platform=iOS Simulator,name=iPhone 8,OS=14.5'
-destination 'platform=iOS Simulator,name=iPhone SE (2nd generation),OS=14.5'
-destination 'platform=iOS,name=caniPhone'

iii. 开启parallel

iOS XCTest实战—解决国际化开发测试痛点(上)中Test Plans栏目介绍了为设备开启parallel testing。在xcode build中同样可以使用-parallel-testing-enabled YES -parallelize-tests-among-destinations开启(不过同时运行多个不同的模拟器,一般没有额外资源对同一个模拟器再开启parallel testing)

xcodebuild test -workspace UITestDemo.xcworkspace -scheme UITestDemoUITests -derivedDataPath '/Users/cc/Desktop/outData'
-parallel-testing-enabled YES -parallelize-tests-among-destinations
-destination 'platform=iOS Simulator,name=iPhone 12 Pro Max,OS=14.5'
-destination 'platform=iOS Simulator,name=iPhone 12,OS=14.5'
-destination 'platform=iOS Simulator,name=iPhone 8 Plus,OS=14.5'
-destination 'platform=iOS Simulator,name=iPhone 8,OS=14.5'
-destination 'platform=iOS Simulator,name=iPhone SE (2nd generation),OS=14.5'
-destination 'platform=iOS,name=caniPhone'

系统会按照可用资源同时并行运行多个模拟器(一般是5个),当你指定超过5个,最开始运行结束的模拟器会自动关闭然后运行下一个。

更多的xcodebuild详见:

man xcodebuild

6. 从xcresult中提取截图

上面提到过要看Test Plans运行完后的截图,只能在Xcode中查看,并且每次只能查看一个配置,图片也只能一次查看一张。
在Xcode中找到测试结果,在Finder中显示可以看到结果是以.xcresult结尾的文件

点击显示包内容后发现结果也无法读取。

通过查阅官方文档:《View and share test results》得知,可以使用xcrun xcresulttool命令来导出结果。
更多命令请查看

xcrun xcresulttool --help 
//or
man xcresulttool

i.xcparse

但是其实不用这么麻烦,因为在Github上有很好用的开源库:xcparse

通过brew安装后,运行xcparse命令即可

//install  xcparse
brew install chargepoint/xcparse/xcparse

//run xcparse
xcparse screenshots --os --model --test-plan-config /path/to/Test.xcresult /path/to/outputDirectory

得到的结果就会按机型、语言分组展示,清晰明了。

7. 最终方案Shell脚本

做完上述所有操作,就已经基本满足需求了。但是还是差那么点意思,目前还存在以下问题:

  • 每次改模拟器,都需要修改xcodebuild命令
  • 输出path要手动指定,如果存在也不会覆盖会一直增加
  • 运行完xcodebuild命令后,要导出图片还要手动执行xcparse命令
  • 无法部署到Jenkins等让非开发人员去测试
    ….

所以我用了一段时间后,决定要shell脚本封装一下,做到一键操作。只需要一个命令,就可以自动执行Test Plans,拿到xcresult后自动执行xcparse命令导出到指定路径。

因为所有的难题之前已经解决了,脚本也是水到渠成。逻辑也很简单

i.脚本

  • 先指定scheme名字

  • 指定xcresult输出目录和xcparse图片输出目录,之前存在就覆盖

  • 配置模拟器和真机List

  • test plans运行完毕后拿到xcresult,用xcparse导出图片

代码如下:

#!/bin/sh

# UITest.sh
# UDictionary
#
# Created by 李伟灿 on 2021/8/24.
# Copyright © 2021 com.youdao. All rights reserved.


#chmod +x UITest.sh
#./UITest.sh

echo "=========开始执行========="

path=$(pwd)
echo "path is $path"

scheme="UDictionary"

#输出目录
outPath="$HOME/Desktop/outData"
resultPath="$HOME/Desktop/outResult"

#XCUITest function
xcUITestFunc(){

if test -e $scheme.xcodeproj
then
echo '=========Xcode Project存在'
else
echo '=========Xcode Project不存在 请检查执行路径'
exit
fi



if test -e $outPath
then
echo "=========outPath existed, clean outPath"
rm -rf $outPath
fi

mkdir $outPath
echo "=========outPath mkdir"

#Get All Devices

#xcrun xctrace list devices

#兼容真机和模拟器 不过最好模拟器一起跑 真机一起跑
simulators=(
#iPhone
"platform=iOS Simulator,name=iPhone 12 Pro Max,OS=15.0"
"platform=iOS Simulator,name=iPhone 12,OS=15.0"
"platform=iOS Simulator,name=iPhone 8 Plus,OS=15.0"
"platform=iOS Simulator,name=iPhone 8,OS=15.0"
"platform=iOS Simulator,name=iPhone SE (2nd generation),OS=15.0"

#iPad
"platform=iOS Simulator,name=iPad (9th generation),OS=15.0"
"platform=iOS Simulator,name=iPad mini (6th generation),OS=15.0"
"platform=iOS Simulator,name=iPad Air (4th generation),OS=15.0"
"platform=iOS Simulator,name=iPad Pro (9.7-inch),OS=15.0"
"platform=iOS Simulator,name=iPad Pro (11-inch) (3rd generation),OS=15.0"
"platform=iOS Simulator,name=iPad Pro (12.9-inch) (5th generation),OS=15.0"
)

destinationStr=""

for subSimulator in "${simulators[@]}"
do
tmpStr="-destination '$subSimulator' "
destinationStr=$destinationStr$tmpStr
done
echo $destinationStr

#拼接命令
commandStr="xcodebuild test -workspace $scheme.xcworkspace -scheme $scheme -derivedDataPath '$outPath' $destinationStr"

echo $commandStr

#执行命令
eval $commandStr

echo "=========XCTestPlan执行结束========="
echo "----------------------------------"
echo "----------------------------------"

}


getAllScreenShots(){

if test -e $resultPath
then
echo "=========resultPath existed, clean resultPath"
rm -rf $resultPath
fi

#find xcresult
xcresultpath=$(find $outPath/Logs/Test -name "*.xcresult")


echo "=========即将输出图片到$resultPath"

#Install xcparse
#brew install chargepoint/xcparse/xcparse

#执行xcparse
commandStr="xcparse screenshots --os --model --test-plan-config $xcresultpath $resultPath"
echo $commandStr

#执行命令
eval $commandStr

echo "=========xcparse执行结束========="

}


xcUITestFunc

getAllScreenShots

ii.运行脚本

运行方式也很简单,在Terminal中找到工程目录,执行Shell脚本,比如我们Shell脚本为UITest.sh

chmod +x UITest.sh
./UITest.sh

8.结语

至此,UI自动走查方案已经全部完成了。有了这个脚本后,我们工程每个版本开发自测和测试平均节省了5/人/天的工作量。特别是后来我们开启了iPad适配后。而UI走查也能看到最终结果的真实呈现。目前已经稳定运行了4个多月,后续还会根据需求继续优化。
本方案设计到很多技术点,比如XCTestTest PlansxcodebuildStoreKit Configuration等等,每一部分单独拎出来说都可以单独写一篇。
所以本文很多地方都一笔带过,主要讲方案的选择和融合。如果哪方面有疑问欢迎大家联系我进行讨论和交流。

联系方式:

李伟灿
网易有道 国际词典项目组
Github
liweican#corp.netease.com
liweican1992#163.com

感谢您的阅读~

海外项目一定离不开国际化适配,而国际化文案的展示&机型适配一直是开发和测试中的痛点。
本人负责的项目一直深耕海外,目前支持13种国际化语言包括R2L的阿拉伯语。作为订阅类App,需要进行大量的订阅页AB测试。本文主要介绍了在项目中如何利用XCTestTest PlanStoreKit Configurationxcodebuild命令、Shell脚本进行快速UI走查的。

0.国际化痛点

在国际化日常开发中,国际化文案适配是一个难题,除了开发需要考虑各种换行和边界问题等,测试同学也要花精力挨个语种和机型进行走查。尤其是订阅页面各个国家的货币展示页不相同。
下图列举了一些国际化常见的问题:

⬆️换行后依旧无法展示

⬆️文案过长,小屏幕机型展示问题

⬆️货币价格过长,需要额外适配


这些问题都需要开发自测或UI走查才能发现,下面我们一起来解决这些痛点。

1.需求梳理

开始之前先梳理一下需求,通过平时我自测的痛点以及和测试同学的沟通,如果用脚本进行测试需要达到以下效果:

  1. 所有国际化文案和主要机型都要覆盖
  2. 以页面截图为准,人工过一下,方便发现问题和报bug,最重要的是方便后期UI Debug。
  3. 对订阅功能,可以方便切换不同国家展示不同的货币
  4. 脚本要方便使用,可以方便配置不同的机型、国际化语言和货币币种,后期方便部署到Jenkins
  5. 结果呈现要分机型、iOS系统,通过截图名称可以知道是哪个页面

2.XCTest

XCTest是Xocde的原生测试框架。相对于KiwiSpectaExpecta等第三方框架更容易上手。而选择XCTest还有一个重要的原因是Apple在2019 WWDC推出的Test Plans对国际化测试非常友好。

(详见:Testing in Xcode,下文也会详细介绍)

Xcode在创建项目时候一般会自动创建Test和UITest工程,因为只需要测试UI,使用UITest即可。

具体的XCTest文档详见User Interface Tests,这里就不展开讲了。

在开始写测试脚本之前,我们需要解决以下几个问题:

i.对页面进行截图

截图方法很简单,封装成一个方法如下:

func takeAScreenshot(_ name: String) {
let screenshot = XCUIScreen.main.screenshot()
let screenshotAttachment = XCTAttachment(
uniformTypeIdentifier: "public.png",
name: "Screenshot-\(UIDevice.current.name)-\(name).png",
payload: screenshot.pngRepresentation,
userInfo: nil)

screenshotAttachment.lifetime = .keepAlways
add(screenshotAttachment)
}

还可以指定截图的质量,比如我们工程运行一次脚本需要截图500+,就需要指定低质量了。经过测试低质量的图片占用大小比高质量能压缩近20倍左右。但是需要注意,在M1芯片的Mac上,指定质量的截图方法会报错,应该是Xcode的适配问题。

func takeAScreenshot(_ name: String) {
let screenshot = XCUIScreen.main.screenshot()
let screenshotAttachment = XCTAttachment.init(screenshot: screenshot, quality: .low)
screenshotAttachment.name = "\(UIDevice.current.name)-\(name).jpeg"
screenshotAttachment.lifetime = .keepAlways
add(screenshotAttachment)
}

测试完成后,截图可以在Xcode中查看

ii. 国际化适配

在XCTest中,我们可以模拟用户的操作,比如点击一个按钮,点击一个Label等,而找到控件的方式一般是按Button文本或者Label的Text.
比如我页面有一个Button,title是“testButton”

func testExample() throws {
// UI tests must launch the application that they test.
let btn = app.buttons["testButton"]
XCTAssertTrue(btn.exists)
//tap btn
btn.tap()
sleep(2)
takeAScreenshot("button")
}

但是当我开启国际化后,按钮文案是跟随系统语言变化的,那上述代码就会执行失败。

解决办法是为控件设置accessibilityIdentifier属性。accessibilityIdentifier是专为UITest设计的。可以方便的找到需要的元素。

//为btn设置accessibilityIdentifier
btn.accessibilityIdentifier = "myTestButton"

//在XCTest中 按accessibilityIdentifier查找
let btn = app.buttons["myTestButton"]

iii. 主App开启测试环境

XCTest每次运行都会重新运行App,一般主App启动入口会有很多业务逻辑,对测试会造成影响。我们需要判断本次启动是从XCTest启动的,这样可以开启测试环境,去掉一些无关的逻辑。
在UITest中可以向launchArguments中加入自定义参数。

class UITestDemoUITests: XCTestCase {
var app: XCUIApplication!

override func setUpWithError() throws {

continueAfterFailure = false
app = XCUIApplication()
//向主App传递参数 也可以写在测试方法中
app.launchArguments.append("UDUIXCTestConfig#SubscribePage")
app.launch()
}
}

AppdelegatedidFinishLaunchingWithOptions中判断,可以指定根据字符串指定需要测试的场景。

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
...
for subArgue: String in CommandLine.arguments {
if subArgue.hasPrefix("UDUIXCTestConfig") {
//开启测试模式
UDXCTestManager.shareInstance().argument = subArgue
UDXCTestManager.shareInstance().testModeOn = true
break
}
}
...
}

iv. Demo

开启上述步骤后,已经可以初步进行UI测试了,通过设置测试环境,确保启动后进入待测试页面。合理利用for循环以及截图。需要注意接入前如果涉及页面跳转等,要用sleep()函数等待页面渲染完后再截图

比如我们工程中一个测试方法

func testSubscribePage() throws {


if #available(iOS 14.0, *) {
let session = try? SKTestSession.init(configurationFileNamed: "Configuration")
session?.disableDialogs = true
session?.clearTransactions()
} else {

}

app.launchArguments.append("UDUIXCTestConfig#SubscribePage")
app.launch()

let TypeList = ["S", "T", "U", "V", "V3"]
for sub in TypeList {
let typeElement = app.tables.staticTexts[sub]
XCTAssertTrue(typeElement.exists)
typeElement.tap()
sleep(2)
self.takeAScreenshot("\(sub) List")

//测试子页面
let list = ["weekly_free", "monthly_free", "yearly_free"]
for sublist in list {
sleep(2)
let subtypeElement = app.tables.staticTexts[sublist]
XCTAssertTrue(subtypeElement.exists)
//进入待测试页
subtypeElement.tap()
sleep(2)
self.takeAScreenshot("\(sub)-\(sublist)-1")
sleep(1)
if sub == "S" || sub == "T" {
//滚动到底部
app.swipeUp()
sleep(1)
//对底部截图 无法滚动的页面可以考虑过滤掉
self.takeAScreenshot("\(sub)-\(sublist)-2")
sleep(3)
} else {
sleep(4)
}

}

//返回上一页面
let backButton = XCUIApplication().navigationBars.buttons["backButton"]
XCTAssert(backButton.exists)
backButton.tap()
sleep(2)

}

}

3.TestPlans

上述方案每次只能测试一个国际化语言,每次切换语言后需要重新运行。在TestPlans推出之前需要我们自己写xcodebuild命令来指定国际化语言或者在Schemes中配置App Langauge

TestPlans是Apple在2019年WWDC推出的测试工具,详细信息详见《Testing in Xcode》, 推出后可以更方便的进行国际化语言以及其余配置的切换。

开启方式也很简单,在“Edit Scheme..”—> “Test” —> “Covert to use Test Plans”


选择放到主工程中,设置成Default

i. 配置Configuration

在主工程中找到.xctestplan后缀的文件。

可以发现在Configuration中可以设置语言、地区、开启Code Coverage等功能。

可以点击加号加新的配置文件,默认的配置是读取Share Settings的配置。

比如我们的工程,配置了主要适配的语言。

ii. 选择测试方法、开启并行

在Test面板,可以看到所有测试方法,可以勾选需要测试的方法,勾选多个会按着从上到下的顺序执行。在Options中打开Execute in parallel。打开后可以在资源允许的情况下,开启多个克隆的模拟器,提高测试速度。

比如执行Phone X机型的一个测试方法,在Configuration中配置了6个配置文件,资源允许情况下会开启3个iPhone X模拟器,每个模拟器跑2个配置,速度提高了3倍。

iii. 运行Test Plans

Test Plans的运行方式和之前一样,找到之前的方法入口,点击运行或者按右键只运行部分配置

iv. 查看结果

Test Plans运行完后可以在Xcode中查看结果,如果运行多个配置,每个配置的结果都会单独呈现

国际化测试需要注意,在Configuration中有Localization Screenshots选型,默认是On,只要本页面文件进行了国际化(使用NSLocalizedString)都会自动截图,如不需要可以关闭。

到目前为止,我们得到了一个初步的测试方案,可以用XCTest写完页面截图逻辑后,使用Test Plans批量运行多个语言。下一章会介绍如何解决订阅中多币种的适配、如何快速导出测试截图以及最终用xcodebuild和shell脚本自动化所有操作。

4.iOS XCTest实战—解决国际化开发测试痛点(下)

下篇详见:iOS XCTest实战—解决国际化开发测试痛点(下)

XLIFF(XML Localisation Interchange File Format,即XML本地化交换文件格式)是一种基于XML的交换格式,旨在标准化本地化过程中在工具之间传递可本地化数据的方式,是CAT工具中常用的一种文件格式。XLIFF由结构化信息标准促进组织(OASIS)于2002年标准化,目前规范为2014年8月5日发布的v2.0
                                   ——Wiki


update:升级到Xcode13以后,Export Localizationns 会报错,需要在Build Setting中设置Use Compiler to Extract Swift StringsNO。多个targert的话每个target都设置即可。


Apple对国际化文案的管理也是基于XLIFF的,这几年一直负责海外项目。国际化文案翻译和录入是必不可免的。XLIFF是业内的通用做法。

如果你对XLIFF不了解,可以参考WWDC Session 401:
《Localizing with Xcode 9》

Apple在2018年升级了国际化文案管理方式,从XLIFF升级到了Localization Catalog。不过本质上文案管理还是XLIFF。

具体参考WWDC Session404:
《New Localization Workflows in Xcode 10》

Localization Catalog 解决了XLIFF的单一性,可以让翻译人员根据上下文语境更准确的翻译。



一般来说,正确的方法是从Xcode中生成Localization Catalog,直接把Localization Catalog给到翻译人员,翻译人员根据storyboard或者图片结合上下文,对文案进行翻译,并且录入到XLIFF中。然后我们只需要导入到Xcode中就可以了。


Read more »

最近一年的工作重心都在海外订阅项目上。ROI数据还不错,所以AB测试越做越多。
近期要实现一个根据国家和地区来下发不同的SKU的需求。记录一下。

判断用户的国家和地区可以简化为判断用户Apple ID的地区,也就是App Store地区。

上次和Apple交流,问了这个为问题。告知并没有提供相关API,原因是你App所选的销售地区就是你内购提供的地区,用户可以转区,转区后所订阅的项目也能在新地区提供。

但是我记得WWDC 2019中,介绍了一个iOS13新增的API,可以监听App Store地区的变化。

WWDC详见:

《In-App Purchases and Using Server-to-Server Notifications》

新增的API是SKStorefront

SKStorefront

官方文档见:
https://developer.apple.com/documentation/storekit/skstorefront

获取地区代码

if #available(iOS 13.0, *) {
print(SKPaymentQueue.default().storefront?.countryCode ?? "")
} else {
}

注意有可能为nil

countryCode使用的是ISO 3166-1 Alpha-3代码

SKStorefront还可以通过paymentQueueDidChangeStorefront监听App Store地区的变化

func paymentQueueDidChangeStorefront(_ queue: SKPaymentQueue) {
if let storefront = queue.storefront {
// Refresh the displayed products based on the new storefront.
for product in storeProducts {
if shouldShow(product.productIdentifier, in: storefront) {
// Display this product in your store UI.
}
}
}
}

Apple官方文档说SKStorefront随时可能变化,甚至是在购买过程中,因此新增了一个代理方法paymentQueue:shouldContinueTransaction:inStorefront:

此方法的作用是在购买时候发生SKStorefront变化,可以判断要不要继续执行这个Transaction。

SKPaymentQueue.default().delegate = self  // Set your object as the SKPaymentQueue delegate.

func paymentQueue(_ paymentQueue: SKPaymentQueue,
shouldContinue transaction: SKPaymentTransaction,
in newStorefront: SKStorefront) -> Bool {
return shouldShow(transaction.payment.productIdentifier, in: newStorefront)
}

如果此地区不支持购买paymentQueue:shouldContinueTransaction:inStorefront:返回false。在Transaction回调中就会收到一个SKErrorStoreProductNotAvailable的Error信息

func paymentQueue(_ queue: SKPaymentQueue,
updatedTransactions transactions: [SKPaymentTransaction]) {
for transaction in transactions {
if let transactionError = transaction.error as NSError?,
transactionError.domain == SKErrorDomain
&& transactionError.code == SKError.storeProductNotAvailable.rawValue {
// Show an alert.
}
}
}

但是SKStorefront只支持iOS13+,并不满足我们的需求。因此还要想别的办法。

通用做法

我们的需求是根据国家和地区,下发不同比例的SKU,不存在这个SKU只在这个地区销售的情况。因为订阅类型的SKU,用户可以在订阅管理里面切换套餐

我的做法是从SKU价格处理入手,因为我们是海外项目,在全球销售。所以要要呈现用户所在地区的价格和货币。可以通过NumberFormatter进行转换

let formatter = NumberFormatter.init()
formatter.formatterBehavior = NumberFormatter.Behavior.behavior10_4
formatter.numberStyle = NumberFormatter.Style.currency
//SKProduct中有priceLocale
formatter.locale = product!.priceLocale
let price :String = formatter.string(from: self.product!.price) ?? "$4.99"

从上述代码可以看到,SKProduct中有个priceLocale属性,赋值给NumberFormatter后可以对价格进行处理。

priceLocale是Local类型(OC中是NSLocal)

Local中有identifierregionCode等属性。regionCode代表的就是地区,可能为nil,identifier是标识符,是一个字符串。

下图是App Store切换到澳大利亚地区,打印的结果

下图是美国

但是regionCode可能为nil,所以要做下判断,当regionCode为nil时候使用identifier,或者跟着业务逻辑调整。

identifier的格式一般是都是固定的,可以根据@符分割,拿到前面的语言_地区的值。

如何快速切换App Store地区可以看我之前写的这篇文章《iOS开发中一些小工具》 使用Switcher这个工具。安装地址:http://switchr.imagility.io/

这个办法必须先获取一个可用的SKProduct,并不能在请求SKProduct之前拿到地区。所以还是有一定的局限性。

我一般是在productsRequest:didReceiveResponse:回调中,拿到一个SKProduct获取到地区后就存起来。有个这个参数也可以在埋点上报中用到,标明用户的App Store地区。

本文从源码分析GCD中的DispatchGroup是怎么调度的,notify的背后是如何实现的。如果你对Swift中GCD如何使用不太了解。可以参考《详解Swift多线程》

API

以下代码是DispatchGroup的常用使用场景

        let g1 = DispatchGroup.init()

//直接输入notify null
g1.notify(queue: DispatchQueue.global()) {
print("notify null")
}

//A B 并发 A B 完成后开启C任务

g1.enter()
DispatchQueue.global().async {
for _ in 0...3{
print("task A: \(Thread.current)")
}
g1.leave()
}

g1.enter()
DispatchQueue.global().async {
for _ in 0...3 {
print("task B: \(Thread.current)")
}
//sleep(UInt32(3.5))
g1.leave()
}

g1.notify(queue: DispatchQueue.global()) {
print("notify A&B")
}

g1.notify(queue: DispatchQueue.global()) {
print("notify A&B again")
}



//打印结果
notify null
task A: <NSThread: 0x600002be85c0>{number = 4, name = (null)}
task B: <NSThread: 0x600002bc5300>{number = 6, name = (null)}
task A: <NSThread: 0x600002be85c0>{number = 4, name = (null)}
task B: <NSThread: 0x600002bc5300>{number = 6, name = (null)}
task A: <NSThread: 0x600002be85c0>{number = 4, name = (null)}
task A: <NSThread: 0x600002be85c0>{number = 4, name = (null)}
task B: <NSThread: 0x600002bc5300>{number = 6, name = (null)}
task B: <NSThread: 0x600002bc5300>{number = 6, name = (null)}
notify A&B
notify A&B again

由以上代码结果可以得知,notity之前没有调用enter()和levae()会直接被调用。
如果在notity之前调用了enter()和leave()。notify会在最后一个leave()调用后才会回调。

wait()的使用

let g1 = DispatchGroup.init()

g1.enter()
DispatchQueue.global().async {
for _ in 0...3{
print("task A: \(Thread.current)")
}
g1.leave()
}

g1.enter()
DispatchQueue.global().async {
for _ in 0...3 {
print("task B: \(Thread.current)")
}
sleep(UInt32(3.5))
g1.leave()
}


let result = g1.wait(timeout: .now() + 3)


switch result {
case .success:
g1.notify(queue: DispatchQueue.global()) {
DispatchQueue.global().async {
for _ in 0...3 {
print("task D: \(Thread.current)")
}
}
}

case .timedOut:
print("timedOut")
break
}

//打印结果
timedOut

查看源码

Swift使用的GCD是桥接OC的源码。所以底层还是libdispatch。

源码可以去opensource下载:https://opensource.apple.com/tarballs/libdispatch/

也可以去github上Apple官方仓库去下载:https://github.com/apple/swift-corelibs-libdispatch

要注意Apple的源码是一直在迭代升级的。封装也是越来越深,在opensource上可以看到很多版本的源码。写这篇文章时候最新版本为1173.40.5版本。本文分析基于931.60.2版本。网速很多资料的源码都是很老的187.9版本之前。内部实现变动很大。

下载源码后,可以在semaphore.c中找到DispatchGroup的实现。

create

先来看看dispatch_group_create的实现


//libdispatch-913.60.2.tar.gz

dispatch_group_t
dispatch_group_create(void)
{
return _dispatch_group_create_with_count(0);
}


//而网上的资料一般都比较老
//一般是 libdispatch-187.9.tar.gz 或者之前
//这是旧的代码 可以看到传入的值是LONG_MAX
dispatch_group_t
dispatch_group_create(void)
{
return (dispatch_group_t)dispatch_semaphore_create(LONG_MAX);
}

_dispatch_group_create_with_count的实现

DISPATCH_ALWAYS_INLINE
static inline dispatch_group_t
_dispatch_group_create_with_count(long count)
{
//dispatch_group_t就是dispatchGroup
//dispatch_group_t本质上就是dispatch_group_s 详见下方
dispatch_group_t dg = (dispatch_group_t)_dispatch_object_alloc(
DISPATCH_VTABLE(group), sizeof(struct dispatch_group_s));
//把count的值存进去结构体
_dispatch_semaphore_class_init(count, dg);

/**
如果有值 就执行os_atomic_store2o

_dispatch_group_create_and_enter 就是传入1进来
dispatch_group_t
_dispatch_group_create_and_enter(void){
return _dispatch_group_create_with_count(1);
}
**/

if (count) {
os_atomic_store2o(dg, do_ref_cnt, 1, relaxed); // <rdar://problem/22318411>
/**
#define os_atomic_store2o(p, f, v, m) \
注意 &(p)->f
等于把1存进dg.do_ref_cnt
os_atomic_store(&(p)->f, (v), m)
**/
}
return dg;
}

我们一个一个来分析

通过搜索发现dispatch_group_t本质上就是dispatch_group_s

dispatch_group_s是一个结构体

struct dispatch_group_s {
DISPATCH_SEMAPHORE_HEADER(group, dg);
//看名字知道和wait方法有关
int volatile dg_waiters;

//dispatch_continuation_s可以自行搜索 最后是个dispatch_object_s
//这里可以理解为存储一个链表的 链表头和尾。看参数名知道和notify有关
struct dispatch_continuation_s *volatile dg_notify_head;
struct dispatch_continuation_s *volatile dg_notify_tail;
};

从上面代码可以看到,creat方法创建了一个dispatch_group_t(也是dispatch_group_s)出来,默认传进来的count是0,并且把count通过dispatch_semaphore_class_init(count, dg)存了起来。

dispatch_semaphore_class_init


//_dispatch_semaphore_class_init(count, dg);
static void
_dispatch_semaphore_class_init(long value, dispatch_semaphore_class_t dsemau)
{
//dsemau就是dg 本质就是把传递进来的count存起来
struct dispatch_semaphore_header_s *dsema = dsemau._dsema_hdr;

dsema->do_next = DISPATCH_OBJECT_LISTLESS;
dsema->do_targetq = _dispatch_get_root_queue(DISPATCH_QOS_DEFAULT, false);
//value就是传进来的count
dsema->dsema_value = value;
_dispatch_sema4_init(&dsema->dsema_sema, _DSEMA4_POLICY_FIFO);
}

ok,通过creat方法我们知道我们创建了一个dispatch_group_s出来,并且把0存了起来。知道dispatch_group_s中有一个类似链表的头和尾,看参数名知道和notify有关。

enter()

enter() 本质上调用dispatch_group_enter()

dispatch_group_enter

void
dispatch_group_enter(dispatch_group_t dg)
{
//os_atomic_inc_orig2o是宏定义,可以一直点进去看。本质上就是把dg的dg_value做+1操作。
long value = os_atomic_inc_orig2o(dg, dg_value, acquire);

if (slowpath((unsigned long)value >= (unsigned long)LONG_MAX)) {
DISPATCH_CLIENT_CRASH(value,
"Too many nested calls to dispatch_group_enter()");
}
if (value == 0) {
_dispatch_retain(dg); // <rdar://problem/22318411>
}
}

从源码上看enter没做其余的操作,就是把dg的dg_value做+1操作。如果dg_value值过大就会crash。

leave()

那么同理我们可以想到leave()应该是做-1操作。

void
dispatch_group_leave(dispatch_group_t dg)
{
//dg_value -1
long value = os_atomic_dec2o(dg, dg_value, release);
if (slowpath(value == 0)) {
//当value==0 执行_dispatch_group_wake
return (void)_dispatch_group_wake(dg, true);
}
//不成对出现 crash
if (slowpath(value < 0)) {
DISPATCH_CLIENT_CRASH(value,
"Unbalanced call to dispatch_group_leave()");
}
}

从源码得知,leave的核心逻辑是判断value==0时候执行_dispatch_group_wake。同时当levae次数比enter多时候,value<0会crash

同时真正执行的逻辑应该在_dispatch_group_wake中

notify()

DISPATCH_ALWAYS_INLINE
static inline void
_dispatch_group_notify(dispatch_group_t dg, dispatch_queue_t dq,
dispatch_continuation_t dsn)
{
//把目标队列保存到dc_data中
dsn->dc_data = dq;
dsn->do_next = NULL;
_dispatch_retain(dq);
if (os_mpsc_push_update_tail(dg, dg_notify, dsn, do_next)) {
_dispatch_retain(dg);
os_atomic_store2o(dg, dg_notify_head, dsn, ordered);
// seq_cst with atomic store to notify_head <rdar://problem/11750916>
//判断dg.dg_value是否为0
if (os_atomic_load2o(dg, dg_value, ordered) == 0) {
_dispatch_group_wake(dg, false);
}
}
}

可以看到,核心逻辑还是dg.davalue为0的话,就直接调用_dispatch_group_wake。所以可以解释为什么notify调用之前没有enter和leave为什么会直接被回调。因为没有enter和leave,dg_value为0,直接调用_dispatch_group_wake

_dispatch_group_wake()

可以说DispatchGroup的核心逻辑就在_dispatch_group_wake方法中

先来看看源码实现

DISPATCH_NOINLINE
static long
_dispatch_group_wake(dispatch_group_t dg, bool needs_release)
{
dispatch_continuation_t next, head, tail = NULL;
long rval;

// cannot use os_mpsc_capture_snapshot() because we can have concurrent
// _dispatch_group_wake() calls

//dispatch_group_s 中dg_notify_head
head = os_atomic_xchg2o(dg, dg_notify_head, NULL, relaxed);

if (head) {
// snapshot before anything is notified/woken <rdar://problem/8554546>
tail = os_atomic_xchg2o(dg, dg_notify_tail, NULL, release);
}
rval = (long)os_atomic_xchg2o(dg, dg_waiters, 0, relaxed);
if (rval) {
// wake group waiters
_dispatch_sema4_create(&dg->dg_sema, _DSEMA4_POLICY_FIFO);
_dispatch_sema4_signal(&dg->dg_sema, rval);
}
uint16_t refs = needs_release ? 1 : 0; // <rdar://problem/22318411>
if (head) {
// async group notify blocks
do {
next = os_mpsc_pop_snapshot_head(head, tail, do_next);
dispatch_queue_t dsn_queue = (dispatch_queue_t)head->dc_data;
//head就是notify的block 在目标队列dsn_queue上运行
_dispatch_continuation_async(dsn_queue, head);
_dispatch_release(dsn_queue);
} while ((head = next));
refs++;
}
if (refs) _dispatch_release_n(dg, refs);
return 0;
}

是否还记得前面提到的dispatch_group_s中的链表头和尾?

head = os_atomic_xchg2o(dg, dg_notify_head, NULL, relaxed);

这里取出dispatch_group_s中的链表头,如果有链表头再取出链表尾。

核心逻辑在这个do while循环中

if (head) {
// async group notify blocks
do {
next = os_mpsc_pop_snapshot_head(head, tail, do_next);
dispatch_queue_t dsn_queue = (dispatch_queue_t)head->dc_data;
//head就是notify的block 在目标队列dsn_queue上运行
_dispatch_continuation_async(dsn_queue, head);
_dispatch_release(dsn_queue);
} while ((head = next));
refs++;
}

通过head->dc_data拿到目标队列,然后通过_dispatch_continuation_async(dsn_queue, head)将head运行在目标队列上。

那head是什么就一目了然了。这个队列中存储的是notify回调的block

再来看看dispatch_group_s的定义

struct dispatch_group_s {
DISPATCH_SEMAPHORE_HEADER(group, dg);
//看名字知道和wait方法有关
int volatile dg_waiters;

//这里就是把所有notify的回调block存进链表里,然后拿到头结点和尾结点。
struct dispatch_continuation_s *volatile dg_notify_head;
struct dispatch_continuation_s *volatile dg_notify_tail;
};

总结

DispatchGroup 在创建时候会建立一个链表,来存储notify的block回调。

判断notify执行的依据就是dg_value是否为0

当不调用enter和leave时候,dg_value=0,notify的回调会立即执行,并且有多个notify会按照顺序依次调用。

let g1 = DispatchGroup.init()

g1.notify(queue: DispatchQueue.global()) {
print("notify null")
}

g1.notify(queue: DispatchQueue.global()) {
print("notify null2")
}

//直接输入notify null,notify null2

当有enter时候dg_value+1。leave时候-1。
当最后一个leave执行后,dg_value==0。去循环链表执行notify的回调

 	   let g1 = DispatchGroup.init()

//A B 并发 A B 完成后开启C任务

g1.enter()
DispatchQueue.global().async {
for _ in 0...3{
print("task A: \(Thread.current)")
}
g1.leave()
}

g1.enter()
DispatchQueue.global().async {
for _ in 0...3{
print("task B: \(Thread.current)")
}
g1.leave()
}
g1.notify(queue: DispatchQueue.global()) {
print("notify A&B")
}
}

根据源码也得知,enter和leave必须成对出现。

当enter多的时候,dg_value永远大于0,notify不会被执行。

当leave多的时候,dg_value小于0,造成Crash

思考

Apple的API封装的很好,其中的一些设计模式也值得我们学习。

GCD的执行效率特别高,在读源码中发现if判断用了很多slowpath fastpath

void
dispatch_group_leave(dispatch_group_t dg)
{
//dg_value -1
long value = os_atomic_dec2o(dg, dg_value, release);
if (slowpath(value == 0)) {
return (void)_dispatch_group_wake(dg, true);
}
//不成对出现 crash
if (slowpath(value < 0)) {
DISPATCH_CLIENT_CRASH(value,
"Unbalanced call to dispatch_group_leave()");
}
}

这个会再另起一篇博客来研究。

关于DispatchGroup 的wait()实现就不再分析了。大家可以自行下载源码来研究下。

带注释的源码详见Github:

https://github.com/liweican1992/libdispatch

https://github.com/liweican1992/swift-corelibs-foundation

Swift终于在5.x版本变得稳定,先来看看Swift5.1中的GCD如何使用

  • 队列

串行队列

串行队列一般只分配一个线程,队列如果有任务执行是不允许插队。
串行队列中执行任务的线程不允许被当前队列中的任务阻塞(死锁),但是能被其他对列阻塞

默认创建的是串行队列

let queue = DispatchQueue(label: "com.youdao.queueName")

主线程就是串行队列

DispatchQueue.main

常见的主线程死锁

//main Threed
print(1)
DispatchQueue.main.sync {
print(2)
}
print(3)
Read more »

今天简书账号莫名其妙的被封了,加上之前封了好几篇技术文章。终于使我下定决心干掉简书。

用简书已经有5年了,写了20多篇博客,收获了100+的粉丝。

Read more »

update:2020.07.10 FaceBook SDK 又双叒叕崩溃了。这次貌似比上次还严重。通过这两次大范围崩溃,充分暴露了Facebook内部流程出现了很大的问题。这次问题的原因又是SDK远程下方配置文件造成的。

详见:

https://github.com/facebook/facebook-ios-sdk/issues/1427
https://developers.facebook.com/support/bugs/329763701368293/


一大早就接到警告,App崩溃率直线上升。赶紧定位错误,发现是崩在了Facebook SDK上面。

因为我们是海外项目。受影响最大,所以最先接到警告。
看了下,应该是Facebook SDK初始化时候就crash了,同时也有用户反馈App一启动就闪退。

Read more »

public link 是Apple 2018年推出的新功能,可以很低成本的进行外部灰度测试,并且可以零成本的测试内购功能

最近负责的项目进行了重大改版,准备申请Apple推荐。想先发个Beta版本给Apple的编辑人员看一下,也小范围的进行下灰度测试。最方便的办法就是通过Testflight进行测试。

还记得2017年时候申请推荐,发外部测试还需要手动的把Apple工作人员的邮箱添加进去。Apple在2018年推出的public link很好的解决了这个问题。

公共链接可让您与开发团队外部的人员共享您的应用程序,而无需电子邮件或其他联系信息。您可以在社交媒体,消息传递平台,电子邮件活动等上共享公共链接,以扩大Beta测试的可见性。如果您没有一组成熟的外部测试人员,则使用公共链接可能是增加应用程序覆盖面并扩大测试受众的有效方法。

Read more »