licc

App矩阵|出海|投放归因

0%

介绍了如何使用python脚本通过大模型API进行批量文本翻译。本文是对之前Xliff自动录入脚本的补充。

这个脚本是对之前在Xcode上使用的Xliff自动录入脚本的补充。
关于如何在Xcode中使用自动导出,批量录入、导入国际化文案,详见:iOS国际化—xliff自动录入脚本

0.背景介绍

iOS国际化—xliff自动录入脚本在项目中已经稳定运行了4年,之所以使用云端Excle管理翻译文案是考虑到兼容Android端和Web端等。之前每个版本都需要产品同学归纳新增的文案,并通过翻译引擎手动翻译后填写到Excel中。

随着GPT的诞生,翻译工具就切换成了GPT,翻译的更地道,更人性化。
但是每次通过对话框翻译,拿到结果再粘贴到Excel中明显效率有提升空间,并且随着DeepSeek的爆火,发现DeepSeek R1的翻译还会考虑当地风俗习惯等,并且能看到思考过程。所以直接通过脚本调用API效率会更高。

1.需求分析

因为是对之前脚本的补充,那么参考之前脚本逻辑。在xliff.py中存在一个Excel语种标题和Xcode中多语言Bundle Name缩写的映射关系。

Localization.sh中声明需要处理那些语种。

那么直接读取本地Excel并批量翻译后生成一个新Excel的方案会更好。这样结合iOS国际化—xliff自动录入脚本。整个流程都可以实现自动化。

并且之前尝试使用分隔符批量请求并且让大模型返回json格式的翻译结果,明显会增加大模型幻觉,造成翻译结果错位等。

2.脚本实现

DeepSeek的API可以在官网购买,但是推荐第三方平台接入,毕竟可以白嫖(不是…)。我使用的是siliconflow平台。注册送2000w token。欢迎大家通过我的邀请链接注册,或者填写邀请码:gdaCaOmO

GPT的话是通过微软Azure平台接入,详见:Quickstart: Get started using GPT-35-Turbo and GPT-4 with Azure OpenAI Service

下面是整个脚本的实现,默认使用GPT进行翻译,也可以指定DeepSeek,支持修改DeepSeek的模型。模型名称详见:Siliconflow API。注意DeepSeek和GPT的payload格式不一样。

需要提前在本地配置API Key并防止秘钥泄露。

#这种方式只在当前Terminal中生效 如何全局生效请结合自己操作系统 自行操作!
export AZURE_OPENAI_API_KEY="REPLACE_WITH_YOUR_KEY_VALUE_HERE"
export AZURE_OPENAI_ENDPOINT="REPLACE_WITH_YOUR_KEY_VALUE_HERE"
export DEEPSEEK_API_URL="REPLACE_WITH_YOUR_KEY_VALUE_HERE"
export DEEPSEEK_API_KEY="REPLACE_WITH_YOUR_KEY_VALUE_HERE"

依赖包安装可以使用pip进行安装

pip install openpyxl requests openai

脚本实现:

# -*- coding: utf-8 -*-
#liweican@corp.netease.com
#支持gpt+deepseek。请自己在本地配置DEEPSEEK_API_URL/DEEPSEEK_API_KEY/AZURE_OPENAI_ENDPOINT/AZURE_OPENAI_API_KEY 防止秘钥泄露
#eg:
#export AZURE_OPENAI_API_KEY="REPLACE_WITH_YOUR_KEY_VALUE_HERE"
#这种方式只在当前Terminal中生效 如何全局生效请结合自己操作系统 自行操作!

import openpyxl
from openpyxl.styles import PatternFill
import time
import os
import re
import requests
from openai import AzureOpenAI

# 样式配置
AUTO_TRANSLATE_FILL = PatternFill(
start_color="FFD8E4BC",
end_color="FFD8E4BC",
fill_type="solid"
)

LANGUAGE_MAPPING = {
"中文简体": "Simplified Chinese",
"中文繁体": "Traditional Chinese",
"Japanese": "Japanese",
"Spanish": "Spanish",
"Korean": "Korean",
"Thailand": "Thai",
"Indonesia": "Indonesian",
"German": "German",
"French": "French",
"Portuguese": "Portuguese"
}

# API配置
DEEPSEEK_API_URL = os.getenv("DEEPSEEK_API_URL")
DEEPSEEK_API_KEY = os.getenv("DEEPSEEK_API_KEY")
#指定模型名称 蒸馏模型速度更快 价格也低
DEEPSEEK_MODEL_NAME = "deepseek-ai/DeepSeek-R1-Distill-Qwen-14B"
AZURE_OPENAI_ENDPOINT = os.getenv("AZURE_OPENAI_ENDPOINT")
AZURE_OPENAI_API_KEY = os.getenv("AZURE_OPENAI_API_KEY")
GPT_API_VERSION = "2024-06-01"
BATCH_SIZE = 8
MAX_TOKENS = 4096

# 解析翻译结果
def parse_translation_result(raw_text, texts):
translated = []
pattern = re.compile(r'^\d+\.\s*(.+)$', re.MULTILINE)
matches = pattern.findall(raw_text)

if len(matches) == len(texts):
translated = [m.strip() for m in matches]
else:
# 备用方案
translated = [line.split(". ", 1)[-1].strip()
for line in raw_text.split("\n")
if line.strip() and line[0].isdigit()]
return translated

# 批量翻译函数
def batch_translate(texts, target_lang, model_choice):
if not texts or not target_lang:
return []

# 构建prompt
numbered_texts = "\n".join([f"{i + 1}. {text}" for i, text in enumerate(texts)])
# 避免Python解析占位符
system_prompt = f"""你是一位专业App内文案翻译人员,请将以下英文文本列表准确翻译为{LANGUAGE_MAPPING[target_lang]}
{numbered_texts}

请严格遵循:
1. 保持专业术语一致性
2. 保留数字和特殊符号,${{TT}}、${{time}}等是占位符均不翻译。
3. 使用正式书面语体
4. 按以下格式返回:
1. 翻译结果
2. 翻译结果"""

if model_choice == "deepseek":
payload = {
"model": DEEPSEEK_MODEL_NAME,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": "请开始翻译"}
],
"temperature": 0,
"max_tokens": MAX_TOKENS
}

headers = {
"Authorization": f"Bearer {DEEPSEEK_API_KEY}",
"Content-Type": "application/json"
}

try:
# 添加超时和重试机制
print(system_prompt)
print("🚀🚀🚀当前DeepSeek模型是" + DEEPSEEK_MODEL_NAME)

response = requests.post(DEEPSEEK_API_URL, json=payload, headers=headers, timeout=(10, 30))
response.raise_for_status()

# status_code
print(f"API响应状态码: {response.status_code}")

try:
response_data = response.json()
print(response_data)
except ValueError:
raise Exception("无效的JSON响应")

if 'choices' not in response_data or not response_data['choices']:
raise Exception("API返回结构异常")

raw_text = response_data['choices'][0]['message']['content']
translated = parse_translation_result(raw_text, texts)
print(translated)
return translated

except Exception as e:
print(f"API调用失败: {str(e)}")
return []
# GPT
elif model_choice == "gpt":
client = AzureOpenAI(
azure_endpoint=AZURE_OPENAI_ENDPOINT,
api_key=AZURE_OPENAI_API_KEY,
api_version=GPT_API_VERSION
)

try:
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": "请开始翻译"}
]
)
raw_text = response.choices[0].message.content
translated = parse_translation_result(raw_text, texts)
print(translated)
return translated
except Exception as e:
print(f"API调用失败: {str(e)}")
return []


def process_excel(input_path, output_path, model_choice):
print(f"开始读取Excel {input_path}")
print(f"当前使用的大模型是: {model_choice.upper()}")

try:
wb = openpyxl.load_workbook(input_path)
sheet = wb.active

source_col = None
for idx, cell in enumerate(sheet[1], 1):
if cell.value and cell.value.strip() == "English":
source_col = idx
break

if not source_col:
raise ValueError("工作表中未找到'English'列,请检查第一行标题")

# 遍历所有目标语言列
for col_idx in range(1, sheet.max_column + 1):
header_cell = sheet.cell(row=1, column=col_idx)
if not header_cell.value:
continue

lang_name = header_cell.value.strip()
if lang_name == "English" or lang_name not in LANGUAGE_MAPPING:
continue

print(f"\n=== 正在处理 {lang_name} ===")
process_language_column(sheet, source_col, col_idx, lang_name, model_choice)

# 保存结果
wb.save(output_path)
print(f"\n处理完成!文件已保存至:{output_path}")

except Exception as e:
print(f"处理过程中发生错误: {str(e)}")
raise


def process_language_column(sheet, source_col, target_col, lang_name, model_choice):
"""处理单个语言列"""
batch_texts = []
batch_positions = []

for row_idx in range(2, sheet.max_row + 1):
source_cell = sheet.cell(row=row_idx, column=source_col)
target_cell = sheet.cell(row=row_idx, column=target_col)

# 跳过空值和已翻译内容
if not source_cell.value or (target_cell.value and str(target_cell.value).strip()):
continue

# 组成批次
batch_texts.append(str(source_cell.value).strip())
batch_positions.append((row_idx, target_col))

if len(batch_texts) >= BATCH_SIZE:
process_batch(sheet, batch_texts, batch_positions, lang_name, model_choice)
batch_texts.clear()
batch_positions.clear()

# 处理剩余批次
if batch_texts:
process_batch(sheet, batch_texts, batch_positions, lang_name, model_choice)


def process_batch(sheet, texts, positions, lang_name, model_choice):
print(f"正在批量翻译 {len(texts)} 条文本到 {lang_name}")

translated = []
for retry in range(3):
try:
translated = batch_translate(texts, lang_name, model_choice)
if len(translated) == len(texts):
break
print(f"第{retry + 1}次重试...")
time.sleep(2 ** retry) # 指数sleep
except Exception as e:
print(f"批处理失败: {str(e)}")

if len(translated) != len(texts):
print(f"未能获取完整翻译,预期{len(texts)}条,实际{len(translated)}条")
translated += [f"[翻译失败] {text}" for text in texts[len(translated):]]

# 写入结果
for (row, col), text in zip(positions, translated):
cell = sheet.cell(row=row, column=col)
cell.value = text
cell.fill = AUTO_TRANSLATE_FILL


if __name__ == '__main__':
import sys
if len(sys.argv) > 1 and sys.argv[1] not in ["deepseek", "gpt"]:
print("请指定有效的模型,可选值为 [deepseek|gpt],默认使用gpt。")
sys.exit(1)

model_choice = sys.argv[1] if len(sys.argv) > 1 else "gpt"
input_file = os.path.expanduser('~/Desktop/test2.xlsx')
output_file = os.path.expanduser('~/Desktop/translated_output.xlsx')

try:
process_excel(input_file, output_file, model_choice)
except Exception as e:
print(f"运行失败: {str(e)}")
exit(1)

使用方式

#gpt
python3 /Users/cc/Desktop/ClassTranslate/autoTranslate.py
#deepseek
python3 /Users/cc/Desktop/ClassTranslate/autoTranslate.py deepseek

脚本逻辑也比较简单,配置了最大token数,支持分批次批量请求,也考虑了翻译失败等情况。翻译填充后会修改Excel文本框颜色,方便人工检测。可以根据各APP不同请求修改system_prompt介绍,让翻译更准确。

脚本Git地址:https://gitlab.corp.youdao.com/liwc/excelToXilff/-/blob/master/autoTranslate.py

测试Excel地址:https://gitlab.corp.youdao.com/liwc/excelToXilff/-/blob/master/test1.xlsx
3.效果演示

DeepSeek演示:

GPT效果演示:

这个脚本也可以单独使用,只做本地Excel批量翻译,也可以串联起iOS国际化—xliff自动录入脚本实现Xcode国际化文案录入纯自动化。具体过程不再阐述。

串联效果演示:(可以做到全自动调用 这里我分开是为了检查翻译文本质量 后续觉得翻译稳定 就可以全自动化)

使用过程有任何问题 欢迎联系和交流:

李伟灿

网易有道 国际APP产品部

liweican#corp.netease.com

感谢您的阅读~

背景

国际词典团队致力于打造高质量出海工具类App产品矩阵,目前旗下有

  • 词典翻译类: U-Dictionary、One Translate
  • 语音转写类:iRecord、iTranscribe
  • 电话录音类:iCall Record

在2022年底,iOS端需要开发一个新的文档扫描类产品:iScanner,此类产品在安卓端已经验证了商业化可行性。
开发时间只有短短几周,需要快速进行技术调研和方案确定。
核心功能就是用户使用相机拍摄或相册选择图片,自动框选出图片内的文档轮廓,裁剪后进行方向矫正。
拿到矫正后的图片调用有道自研的文档增加接口和手写擦除接口。

先看一下最终实现效果:

技术选型&实现

客户端需要实现的核心功能有2个:

  1. 文档脚点检测
  2. 图片方向矫正(透视矫正)

1.脚点检测

自研SDK

安卓端的方案:

  1. 文档脚点监测使用算法端提供的自研SDK,底层模型使用C++实现,推理框架用的MNN(Mobile Neural Network)
  2. 图片矫正使用的是opencv的透视变换

不幸的是自研模型并没有提供iOS端的SDK,底层使用C++需要自己桥接下提供相关的接口。
有道还有一套在线的文档脚点检测服务,但是考虑到海外网络环境复杂,最好还是使用离线的。
当然opencv也提供脚点检测的能力,但是识别率太差不在考虑范围内。

那么iOS端有原生能力支持吗?其实熟悉iOS的应该都知道iOS系统备忘录是提供文档扫描的,并且效果很好。
系统内的备忘录支持多张图连续扫描,支持脚点监测和图片方向矫正。
Apple也开放了接口供开发者使用,那就是VisionKit下的VNDocumentCameraViewController

VisionKit

VisionKit是iOS13推出的,为 iOS 提供了图像和实时视频中的文本、结构化数据的检测功能。
VisionKit本质上是对Vision的封装,
而VisionKit下VNDocumentCameraViewController是可以直接使用的,效果如下:

其内置了脚点自动检测,脚点拖拽调整等交互。支持从视频buffer流中直接检测。
但是VNDocumentCameraViewController封装的非常死,无法修改内部任何UI和交互流程,所以只能pass

更多关于VisionKit的信息参考:
Visionkit Documentation
VNDocumentCameraViewController

Vision

既然VisionKit是基于Vision的封装,那么完全可以基于Vision自己来实现所有功能
VNDocumentCameraViewController刚推出时候当时的底层技术是基于VisionVNDetectRectanglesRequest
VNDetectRectanglesRequest会监测图片中所有矩形并返回一个数组,依赖VNDetectRectanglesRequest再加上后期算法,是可以进行文档检测的。

但是Apple在iOS15时候发布了新APIVNDetectDocumentSegmentationRequest
看API的名字就能知道是针对文档进行了单独优化的,那么VNDetectDocumentSegmentationRequestVNDetectRectanglesRequest厉害在哪里呢?

如上图所示,同一张图片,VNDetectRectanglesRequest检测出了所有矩形,而VNDetectDocumentSegmentationRequest准确的检测出了文档。
在底层技术上,区别如下:

DocumentSegmentation基于机器学习并且能够运行在NE(神经网络引擎),CPU或者GPU上面,而VNDetectRectanglesRequest只能在CPU上运行,运行速度上就拉开了很大差距。
DocumentSegmentation还能识别出非矩形形状的文档,还能提供mask和顶角点坐标。
更重要的是VNDetectDocumentSegmentationRequest只会识别出一个文档而不会检测出多个矩形,这无疑减轻了很多工作量,可以直接拿来使用。

另外,Visionkit中的VNDocumentCameraViewController底层技术,在iOS15之后也已经替换成了VNDetectDocumentSegmentationRequest

VNDetectDocumentSegmentationRequest

VNDetectDocumentSegmentationRequest的使用非常简单

func recognize(image: UIImage) -> VNRectangleObservation? {
nextBtn.isEnabled = true
guard let cgImage = image.cgImage else { return nil }
let documentSegmentationHandler = VNImageRequestHandler(cgImage: cgImage)
let documentSegmentationRequest = VNDetectDocumentSegmentationRequest()
documentSegmentationRequest.revision = VNDetectDocumentSegmentationRequestRevision1
try? documentSegmentationHandler.perform([documentSegmentationRequest])
if let result = documentSegmentationRequest.results?.first {
print(result.confidence)
if result.confidence > 0.7 {
return result
}
}
print("未检测到文档")
return nil
}

注意:
在传入图片前请处理好图片的imageOrientation
VNImageRequestHandler也支持ciImage的初始化,可以合理选择使用方式

在result中会返回一个置信度,一般设置大于0.7就认为识别有效,具体参数可以动态调整。
返回结果是VNRectangleObservation类型

@available(iOS 11.0, *)
open class VNRectangleObservation : VNDetectedObjectObservation {
@available(iOS 13.0, *)
public convenience init(requestRevision: Int, topLeft: CGPoint, bottomLeft: CGPoint, bottomRight: CGPoint, topRight: CGPoint)
open var topLeft: CGPoint { get }
open var topRight: CGPoint { get }
open var bottomLeft: CGPoint { get }
open var bottomRight: CGPoint { get }
}

之前用过Vision的同学肯定很熟悉父类VNDetectedObjectObservation,在VNDetectedObjectObservation中有一个boundingBox熟悉

/*!
@brief The bounding box of the detected object. The coordinates are normalized to the dimensions of the processed image, with the origin at the image's lower-left corner.
*/
@property (readonly, nonatomic, assign) CGRect boundingBox;

boundingBox 中的坐标被归一化,意味着 x、y、宽度和高度都是 0.0 到 1.0 之间的小数,同时原点 (0,0) 在左下角,这些都需要我们自己来转换。

同样在文档脚点检测中返回的四个脚点topLeft,topRight,bottomLeft,bottomRight,也是归一化的,起点在左下角,需要进行坐标转换后才能在图片上展示识别框。

这里有一个小技巧,先把原图进行imageOrientation处理和尺寸压缩,展示在屏幕上后建立一个和展示image大小一致的CALayer图层,后续框体展示、拖拽、旋转等都方便处理。

坐标转换代码

let size = self.pathLayer!.bounds
var transform: CGAffineTransform

if UIApplication.shared.statusBarOrientation.isLandscape {
transform = CGAffineTransform.identity
.scaledBy(x: -1, y: 1)
.translatedBy(x: -size.width, y: 0)
.scaledBy(x: size.width, y: size.height)
} else {
transform = CGAffineTransform.identity
.scaledBy(x: 1, y: -1)
.translatedBy(x: 0, y: -size.height)
.scaledBy(x: size.width, y: size.height)
}

//转换为UIKit坐标系
convertedTopLeft = result.topLeft.applying(transform)
convertedTopRight = result.topRight.applying(transform)
convertedBottomLeft = result.bottomLeft.applying(transform)
convertedBottomRight = result.bottomRight.applying(transform)

当然只是框选出来文档框架才是第一步,用户还能拖拽脚点编辑框选区域。所以坐标点还需要反向转换。
做框选中,还需要判断矩形是否有效(内角是否大于180度),可以根据对角线 acbd 对角线是否交叉 根据叉积(向量积)计算

差积算法

private func checkInnerAngleAvailable() -> Bool {
let x1 = self.topLeftPoint.x
let y1 = self.topLeftPoint.y
let x2 = self.bottomRightPoint.x
let y2 = self.bottomRightPoint.y
let x3 = self.bottomLeftPoint.x
let y3 = self.bottomLeftPoint.y
let x4 = self.topRightPoint.x
let y4 = self.topRightPoint.y

if max(x1, x2) < min(x3, x4) || max(y1, y2) < min(y3, y4) || min(x1, x2) > max(x3, x4) || min(y1, y2) > max(y3, y4) {
return false
}

if ((x3 - x1) * (y3 - y4) - (y3 - y1) * (x3 - x4)) * (
(x3 - x2) * (y3 - y4) - (y3 - y2) * (x3 - x4)) <= 0 && (
(x1 - x3) * (y1 - y2) - (y1 - y3) * (x1 - x2)) * (
(x1 - x4) * (y1 - y2) - (y1 - y4) * (x1 - x2)) <= 0 {
return true
}

return false

}

那么脚点监测的最终方案也定下来了,下面来看一下各个方法的识别对比


可以看到Apple Vision下的VNDetectDocumentSegmentationRequest检测效果是优于自研模型的,部分badcase效果还超过也竞品。
当然唯一的限制就是iOS15+才能使用,但是参考Apple提供的系统占比数据,截止到2022年5月 iOS15以上占比89%

update:
Apple在2023年2月14日公布了最新数据,iOS15以上占比96%

到此已经完成了文档脚点检测,接下来要解决图片视觉矫正。

2.透视矫正

图片矫正(透视矫正)在OCR中广泛使用,主流做法就是使用opencv进行处理。
原理有很多,一般是边缘投影的、Hough变换、线性拟合,傅里叶变换图像频域等方法。这里不再展开讲了。

并且opencv里面常用的是矩形监测和矫正一起,我们在第一部分已经完成了脚点检测,只是做矩形矫正就没必要再用opencv了。

Apple VisionKit中的VNDocumentCameraViewController是支持方向矫正的,那么Apple自身提供的API应该是可以实现的。

那提到Apple内置的图像处理,肯定就绕不开CoreImage

CoreImage可以实现多种多样的滤镜效果,滤镜一般基于CIFilter,在CIFilter中就有现成的矩形矫正算法。

更多关于CoreImage的信息参考:
Core Image
CIFilter

CIFilter

要实现矫正,首先要把脚点检测后4个点形成的图片裁剪出来。4个点是归一化后的,先把图片进行CIImage转换,调用cropped方法进行裁剪。

 @objc func clipAction() {
var image = self.cleanImage
guard let ciImage = self.cleanImage.ciImage ?? CIImage(image: self.imageView.image!) else {
return
}

//重新计算resultObservation
//self.updateResultObservation()

DispatchQueue.global().async {
if let result = self.resultObservation {
let imageSize = ciImage.extent.size

let boundingBox = result.boundingBox.scaled(to: imageSize)
let tmpImage = ciImage
.cropped(to: boundingBox)
}
}
......
......

}

//scaled是一个extension
extension CGRect {
func scaled(to size: CGSize) -> CGRect {
return CGRect(
x: self.origin.x * size.width,
y: self.origin.y * size.height,
width: self.size.width * size.width,
height: self.size.height * size.height
)
}
}

拿到裁剪后的图片,就可以进行透视矫正了,iOS中透视矫正使用CIVector
CIVector需要搭配CIFilter使用,可以产生丰富的效果

圆形缠绕过滤器

let newImage = applyingFilter(CICircularWrap, parameters: [kCIInputImageKey: image, kCIInputCenterKey : CIVector.init(x: 100, y: 200), kCIInputRadiusKey : 20, kCIInputAngleKey : 3])


边缘采样过滤器

applyingFilter("CIEdgePreserveUpsampleFilter", parameters: [kCIInputImageKey: image, inputLumaSigma : 0.15, inputSpatialSigma : 3, inputSmallImage : image2])

而我们要使用的就是矩形矫正过滤器CIPerspectiveCorrection

下面是做的一个Extension,只需要传递四个还原后的坐标即可完成矫正

extension CIImage {

func perspectiveCorrect(by observation: VNRectangleObservation, imageSize: CGSize?) -> CIImage {
let imageSize = imageSize ?? self.extent.size
let newImage =
applyingFilter("CIPerspectiveCorrection", parameters: [
"inputTopLeft": CIVector(cgPoint: observation.topLeft.scaled(to: imageSize)),
"inputTopRight": CIVector(cgPoint: observation.topRight.scaled(to: imageSize)),
"inputBottomLeft": CIVector(cgPoint: observation.bottomLeft.scaled(to: imageSize)),
"inputBottomRight": CIVector(cgPoint: observation.bottomRight.scaled(to: imageSize))
])
return newImage
}
}

extension CGPoint {

func scaled(to size: CGSize) -> CGPoint {
CGPoint(x: x * size.width,
y: y * size.height)
}
}

看一下矫正效果


至于其余功能比如最小裁剪大小,旋转,镜像等功能就不再讨论了。至此已经完成了文档扫描的核心功能。

优点有很多

  • 纯离线,无网络要求
  • 脚点检测速度快,能运行在GPU上
  • 纯原生支持,不依赖三方库
  • 效果优于自研SDK

iScanner目前已经上线,欢迎大家去体验,如果有其他疑问也可以和我联系

李伟灿
网易有道-国际App
liweican#corp.netease.com

感谢阅读~

背景

在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等等,每一部分单独拎出来说都可以单独写一篇。
所以本文很多地方都一笔带过,主要讲方案的选择和融合。如果哪方面有疑问欢迎大家联系我进行讨论和交流。

联系方式:

李伟灿
网易有道 国际App
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 »