重要前提
安装AI Skills的关键前提是:必须科学上网,且开启TUN模式,这一点至关重要,直接决定安装能否顺利完成,在此郑重提醒三遍:科学上网,科学上网,科学上网。查看完整安装教程 →
ios-unit-test by dengineproblem/agents-monorepo
npx skills add https://github.com/dengineproblem/agents-monorepo --skill ios-unit-test精通使用 XCTest 框架进行 iOS 测试及最佳实践。
import XCTest
@testable import YourApp
class UserServiceTests: XCTestCase {
// 被测系统
var sut: UserService!
var mockNetworkManager: MockNetworkManager!
override func setUpWithError() throws {
try super.setUpWithError()
mockNetworkManager = MockNetworkManager()
sut = UserService(networkManager: mockNetworkManager)
}
override func tearDownWithError() throws {
sut = nil
mockNetworkManager = nil
try super.tearDownWithError()
}
// MARK: - fetchUser 测试
func test_fetchUser_withValidId_returnsUser() async throws {
// 准备
let expectedUser = User(id: "123", name: "John Doe")
mockNetworkManager.fetchUserResult = .success(expectedUser)
// 执行
let result = try await sut.fetchUser(id: "123")
// 断言
XCTAssertEqual(result.id, expectedUser.id)
XCTAssertEqual(result.name, expectedUser.name)
XCTAssertEqual(mockNetworkManager.fetchUserCallCount, 1)
XCTAssertEqual(mockNetworkManager.lastFetchedUserId, "123")
}
func test_fetchUser_withInvalidId_throwsError() async {
// 准备
mockNetworkManager.fetchUserResult = .failure(NetworkError.notFound)
// 执行 & 断言
do {
_ = try await sut.fetchUser(id: "invalid")
XCTFail("预期抛出错误")
} catch {
XCTAssertTrue(error is NetworkError)
XCTAssertEqual(error as? NetworkError, .notFound)
}
}
}
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
// 协议定义
protocol NetworkManagerProtocol {
func fetchUser(id: String) async throws -> User
func saveUser(_ user: User) async throws
}
// 模拟实现
class MockNetworkManager: NetworkManagerProtocol {
// 调用追踪
var fetchUserCallCount = 0
var lastFetchedUserId: String?
var saveUserCallCount = 0
var lastSavedUser: User?
// 可配置的结果
var fetchUserResult: Result<User, Error>?
var saveUserResult: Result<Void, Error> = .success(())
func fetchUser(id: String) async throws -> User {
fetchUserCallCount += 1
lastFetchedUserId = id
switch fetchUserResult {
case .success(let user):
return user
case .failure(let error):
throw error
case .none:
throw TestError.noMockResult
}
}
func saveUser(_ user: User) async throws {
saveUserCallCount += 1
lastSavedUser = user
switch saveUserResult {
case .success:
return
case .failure(let error):
throw error
}
}
// 重置以便重用
func reset() {
fetchUserCallCount = 0
lastFetchedUserId = nil
saveUserCallCount = 0
lastSavedUser = nil
fetchUserResult = nil
saveUserResult = .success(())
}
}
enum TestError: Error {
case noMockResult
}
class NetworkManagerSpy: NetworkManagerProtocol {
private(set) var messages: [Message] = []
enum Message: Equatable {
case fetchUser(id: String)
case saveUser(User)
}
var stubbedFetchUserResult: Result<User, Error> = .failure(TestError.noMockResult)
func fetchUser(id: String) async throws -> User {
messages.append(.fetchUser(id: id))
return try stubbedFetchUserResult.get()
}
func saveUser(_ user: User) async throws {
messages.append(.saveUser(user))
}
}
func test_fetchUser_withValidId_returnsUser() async throws {
// 准备
let expectedUser = User(id: "123", name: "John Doe")
mockNetworkManager.fetchUserResult = .success(expectedUser)
// 执行
let result = try await sut.fetchUser(id: "123")
// 断言
XCTAssertEqual(result, expectedUser)
}
func test_fetchUser_withNetworkError_throwsError() async {
// 准备
mockNetworkManager.fetchUserResult = .failure(NetworkError.connectionFailed)
// 执行 & 断言
await XCTAssertThrowsError(try await sut.fetchUser(id: "123")) { error in
XCTAssertEqual(error as? NetworkError, .connectionFailed)
}
}
func test_notificationObserver_receivesNotification() {
// 准备
let expectation = XCTestExpectation(description: "收到通知")
let notificationName = Notification.Name("TestNotification")
let observer = NotificationCenter.default.addObserver(
forName: notificationName,
object: nil,
queue: nil
) { _ in
expectation.fulfill()
}
// 执行
NotificationCenter.default.post(name: notificationName, object: nil)
// 断言
wait(for: [expectation], timeout: 1.0)
// 清理
NotificationCenter.default.removeObserver(observer)
}
func test_delegateCallback_isCalledOnSuccess() {
// 准备
let expectation = XCTestExpectation(description: "调用委托")
let mockDelegate = MockDelegate()
mockDelegate.onSuccessCalled = { expectation.fulfill() }
sut.delegate = mockDelegate
// 执行
sut.performOperation()
// 断言
wait(for: [expectation], timeout: 2.0)
XCTAssertTrue(mockDelegate.successCallCount == 1)
}
import Combine
func test_userPublisher_emitsUser() {
// 准备
var receivedUser: User?
var receivedError: Error?
let expectation = XCTestExpectation(description: "发布者发出值")
let cancellable = sut.userPublisher
.sink(
receiveCompletion: { completion in
if case .failure(let error) = completion {
receivedError = error
}
expectation.fulfill()
},
receiveValue: { user in
receivedUser = user
}
)
// 执行
sut.loadUser(id: "123")
// 断言
wait(for: [expectation], timeout: 2.0)
XCTAssertNotNil(receivedUser)
XCTAssertNil(receivedError)
cancellable.cancel()
}
class LoginViewControllerTests: XCTestCase {
var sut: LoginViewController!
var mockAuthService: MockAuthService!
override func setUpWithError() throws {
let storyboard = UIStoryboard(name: "Main", bundle: nil)
sut = storyboard.instantiateViewController(
withIdentifier: "LoginViewController"
) as? LoginViewController
mockAuthService = MockAuthService()
sut.authService = mockAuthService
// 加载视图层次结构
sut.loadViewIfNeeded()
}
override func tearDownWithError() throws {
sut = nil
mockAuthService = nil
}
func test_outlets_areConnected() {
XCTAssertNotNil(sut.emailTextField)
XCTAssertNotNil(sut.passwordTextField)
XCTAssertNotNil(sut.loginButton)
XCTAssertNotNil(sut.errorLabel)
}
func test_loginButton_tap_callsAuthService() {
// 准备
sut.emailTextField.text = "test@example.com"
sut.passwordTextField.text = "password123"
// 执行
sut.loginButton.sendActions(for: .touchUpInside)
// 断言
XCTAssertEqual(mockAuthService.loginCallCount, 1)
XCTAssertEqual(mockAuthService.lastLoginEmail, "test@example.com")
XCTAssertEqual(mockAuthService.lastLoginPassword, "password123")
}
func test_loginButton_withEmptyEmail_showsError() {
// 准备
sut.emailTextField.text = ""
sut.passwordTextField.text = "password"
// 执行
sut.loginButton.sendActions(for: .touchUpInside)
// 断言
XCTAssertEqual(mockAuthService.loginCallCount, 0)
XCTAssertFalse(sut.errorLabel.isHidden)
XCTAssertEqual(sut.errorLabel.text, "邮箱为必填项")
}
func test_successfulLogin_navigatesToHome() {
// 准备
mockAuthService.loginResult = .success(User(id: "1", name: "Test"))
let mockNavigator = MockNavigator()
sut.navigator = mockNavigator
sut.emailTextField.text = "test@example.com"
sut.passwordTextField.text = "password"
// 执行
sut.loginButton.sendActions(for: .touchUpInside)
// 断言
XCTAssertTrue(mockNavigator.didNavigateToHome)
}
}
func test_dataProcessing_performance() {
let largeDataSet = generateLargeDataSet(count: 10000)
measure {
_ = sut.processData(largeDataSet)
}
}
func test_dataProcessing_performanceWithOptions() {
let options = XCTMeasureOptions()
options.iterationCount = 10
measure(options: options) {
_ = sut.processData(generateLargeDataSet(count: 5000))
}
}
func test_memoryUsage_withLargeDataSet() {
let options = XCTMeasureOptions()
options.iterationCount = 5
measure(metrics: [XCTMemoryMetric()], options: options) {
autoreleasepool {
let data = sut.loadLargeDataSet()
sut.processData(data)
}
}
}
func test_cpuUsage_duringOperation() {
measure(metrics: [XCTCPUMetric()]) {
sut.performCPUIntensiveOperation()
}
}
func test_emailValidation_withVariousInputs() {
let testCases: [(email: String, isValid: Bool)] = [
("valid@example.com", true),
("user.name@domain.co.uk", true),
("invalid.email", false),
("", false),
("@example.com", false),
("test@", false),
("test@.com", false),
("test@domain", false)
]
for testCase in testCases {
let result = sut.isValidEmail(testCase.email)
XCTAssertEqual(
result,
testCase.isValid,
"邮箱验证失败: '\(testCase.email)' - 预期 \(testCase.isValid),实际得到 \(result)"
)
}
}
// 使用 XCTestCase 子类实现更清晰的参数化测试
class EmailValidationTests: XCTestCase {
struct TestCase {
let input: String
let expected: Bool
let file: StaticString
let line: UInt
init(_ input: String, _ expected: Bool,
file: StaticString = #file, line: UInt = #line) {
self.input = input
self.expected = expected
self.file = file
self.line = line
}
}
func test_isValidEmail() {
let testCases = [
TestCase("test@example.com", true),
TestCase("invalid", false),
TestCase("", false)
]
for testCase in testCases {
let result = EmailValidator.isValid(testCase.input)
XCTAssertEqual(result, testCase.expected,
file: testCase.file, line: testCase.line)
}
}
}
class LoginUITests: XCTestCase {
var app: XCUIApplication!
override func setUpWithError() throws {
continueAfterFailure = false
app = XCUIApplication()
app.launchArguments = ["--uitesting"]
app.launch()
}
func test_loginFlow_withValidCredentials_showsHomeScreen() {
// 导航到登录页面
let loginButton = app.buttons["LoginButton"]
XCTAssertTrue(loginButton.waitForExistence(timeout: 5))
// 输入凭据
let emailField = app.textFields["EmailTextField"]
emailField.tap()
emailField.typeText("test@example.com")
let passwordField = app.secureTextFields["PasswordTextField"]
passwordField.tap()
passwordField.typeText("password123")
// 点击登录
loginButton.tap()
// 验证主屏幕
let homeTitle = app.staticTexts["Welcome"]
XCTAssertTrue(homeTitle.waitForExistence(timeout: 10))
}
func test_loginFlow_withInvalidCredentials_showsError() {
let emailField = app.textFields["EmailTextField"]
emailField.tap()
emailField.typeText("wrong@example.com")
let passwordField = app.secureTextFields["PasswordTextField"]
passwordField.tap()
passwordField.typeText("wrongpassword")
app.buttons["LoginButton"].tap()
let errorLabel = app.staticTexts["ErrorLabel"]
XCTAssertTrue(errorLabel.waitForExistence(timeout: 5))
XCTAssertEqual(errorLabel.label, "无效凭据")
}
}
test_scheme_configuration:
unit_tests:
targets: ["YourAppTests"]
coverage: true
parallel: true
ui_tests:
targets: ["YourAppUITests"]
coverage: false
parallel: false
launch_arguments: ["--uitesting", "--reset-state"]
integration_tests:
targets: ["YourAppIntegrationTests"]
coverage: true
parallel: false
{
"configurations" : [
{
"name" : "Unit Tests",
"options" : {
"targetForVariableExpansion" : { "target" : { "name" : "YourApp" } }
}
}
],
"defaultOptions" : {
"codeCoverage" : true,
"testTimeoutsEnabled" : true,
"defaultTestExecutionTimeAllowance" : 60
},
"testTargets" : [
{ "target" : { "name" : "YourAppTests" } }
],
"version" : 1
}
test_方法名_条件_预期结果每周安装数
66
代码仓库
GitHub 星标数
3
首次出现
2026年1月29日
安全审计
安装于
codex65
github-copilot65
opencode65
gemini-cli64
claude-code63
cursor63
Expert in iOS testing with XCTest framework and best practices.
import XCTest
@testable import YourApp
class UserServiceTests: XCTestCase {
// System Under Test
var sut: UserService!
var mockNetworkManager: MockNetworkManager!
override func setUpWithError() throws {
try super.setUpWithError()
mockNetworkManager = MockNetworkManager()
sut = UserService(networkManager: mockNetworkManager)
}
override func tearDownWithError() throws {
sut = nil
mockNetworkManager = nil
try super.tearDownWithError()
}
// MARK: - fetchUser Tests
func test_fetchUser_withValidId_returnsUser() async throws {
// Arrange
let expectedUser = User(id: "123", name: "John Doe")
mockNetworkManager.fetchUserResult = .success(expectedUser)
// Act
let result = try await sut.fetchUser(id: "123")
// Assert
XCTAssertEqual(result.id, expectedUser.id)
XCTAssertEqual(result.name, expectedUser.name)
XCTAssertEqual(mockNetworkManager.fetchUserCallCount, 1)
XCTAssertEqual(mockNetworkManager.lastFetchedUserId, "123")
}
func test_fetchUser_withInvalidId_throwsError() async {
// Arrange
mockNetworkManager.fetchUserResult = .failure(NetworkError.notFound)
// Act & Assert
do {
_ = try await sut.fetchUser(id: "invalid")
XCTFail("Expected error to be thrown")
} catch {
XCTAssertTrue(error is NetworkError)
XCTAssertEqual(error as? NetworkError, .notFound)
}
}
}
// Protocol definition
protocol NetworkManagerProtocol {
func fetchUser(id: String) async throws -> User
func saveUser(_ user: User) async throws
}
// Mock implementation
class MockNetworkManager: NetworkManagerProtocol {
// Call tracking
var fetchUserCallCount = 0
var lastFetchedUserId: String?
var saveUserCallCount = 0
var lastSavedUser: User?
// Configurable results
var fetchUserResult: Result<User, Error>?
var saveUserResult: Result<Void, Error> = .success(())
func fetchUser(id: String) async throws -> User {
fetchUserCallCount += 1
lastFetchedUserId = id
switch fetchUserResult {
case .success(let user):
return user
case .failure(let error):
throw error
case .none:
throw TestError.noMockResult
}
}
func saveUser(_ user: User) async throws {
saveUserCallCount += 1
lastSavedUser = user
switch saveUserResult {
case .success:
return
case .failure(let error):
throw error
}
}
// Reset for reuse
func reset() {
fetchUserCallCount = 0
lastFetchedUserId = nil
saveUserCallCount = 0
lastSavedUser = nil
fetchUserResult = nil
saveUserResult = .success(())
}
}
enum TestError: Error {
case noMockResult
}
class NetworkManagerSpy: NetworkManagerProtocol {
private(set) var messages: [Message] = []
enum Message: Equatable {
case fetchUser(id: String)
case saveUser(User)
}
var stubbedFetchUserResult: Result<User, Error> = .failure(TestError.noMockResult)
func fetchUser(id: String) async throws -> User {
messages.append(.fetchUser(id: id))
return try stubbedFetchUserResult.get()
}
func saveUser(_ user: User) async throws {
messages.append(.saveUser(user))
}
}
func test_fetchUser_withValidId_returnsUser() async throws {
// Arrange
let expectedUser = User(id: "123", name: "John Doe")
mockNetworkManager.fetchUserResult = .success(expectedUser)
// Act
let result = try await sut.fetchUser(id: "123")
// Assert
XCTAssertEqual(result, expectedUser)
}
func test_fetchUser_withNetworkError_throwsError() async {
// Arrange
mockNetworkManager.fetchUserResult = .failure(NetworkError.connectionFailed)
// Act & Assert
await XCTAssertThrowsError(try await sut.fetchUser(id: "123")) { error in
XCTAssertEqual(error as? NetworkError, .connectionFailed)
}
}
func test_notificationObserver_receivesNotification() {
// Arrange
let expectation = XCTestExpectation(description: "Notification received")
let notificationName = Notification.Name("TestNotification")
let observer = NotificationCenter.default.addObserver(
forName: notificationName,
object: nil,
queue: nil
) { _ in
expectation.fulfill()
}
// Act
NotificationCenter.default.post(name: notificationName, object: nil)
// Assert
wait(for: [expectation], timeout: 1.0)
// Cleanup
NotificationCenter.default.removeObserver(observer)
}
func test_delegateCallback_isCalledOnSuccess() {
// Arrange
let expectation = XCTestExpectation(description: "Delegate called")
let mockDelegate = MockDelegate()
mockDelegate.onSuccessCalled = { expectation.fulfill() }
sut.delegate = mockDelegate
// Act
sut.performOperation()
// Assert
wait(for: [expectation], timeout: 2.0)
XCTAssertTrue(mockDelegate.successCallCount == 1)
}
import Combine
func test_userPublisher_emitsUser() {
// Arrange
var receivedUser: User?
var receivedError: Error?
let expectation = XCTestExpectation(description: "Publisher emits")
let cancellable = sut.userPublisher
.sink(
receiveCompletion: { completion in
if case .failure(let error) = completion {
receivedError = error
}
expectation.fulfill()
},
receiveValue: { user in
receivedUser = user
}
)
// Act
sut.loadUser(id: "123")
// Assert
wait(for: [expectation], timeout: 2.0)
XCTAssertNotNil(receivedUser)
XCTAssertNil(receivedError)
cancellable.cancel()
}
class LoginViewControllerTests: XCTestCase {
var sut: LoginViewController!
var mockAuthService: MockAuthService!
override func setUpWithError() throws {
let storyboard = UIStoryboard(name: "Main", bundle: nil)
sut = storyboard.instantiateViewController(
withIdentifier: "LoginViewController"
) as? LoginViewController
mockAuthService = MockAuthService()
sut.authService = mockAuthService
// Load view hierarchy
sut.loadViewIfNeeded()
}
override func tearDownWithError() throws {
sut = nil
mockAuthService = nil
}
func test_outlets_areConnected() {
XCTAssertNotNil(sut.emailTextField)
XCTAssertNotNil(sut.passwordTextField)
XCTAssertNotNil(sut.loginButton)
XCTAssertNotNil(sut.errorLabel)
}
func test_loginButton_tap_callsAuthService() {
// Arrange
sut.emailTextField.text = "test@example.com"
sut.passwordTextField.text = "password123"
// Act
sut.loginButton.sendActions(for: .touchUpInside)
// Assert
XCTAssertEqual(mockAuthService.loginCallCount, 1)
XCTAssertEqual(mockAuthService.lastLoginEmail, "test@example.com")
XCTAssertEqual(mockAuthService.lastLoginPassword, "password123")
}
func test_loginButton_withEmptyEmail_showsError() {
// Arrange
sut.emailTextField.text = ""
sut.passwordTextField.text = "password"
// Act
sut.loginButton.sendActions(for: .touchUpInside)
// Assert
XCTAssertEqual(mockAuthService.loginCallCount, 0)
XCTAssertFalse(sut.errorLabel.isHidden)
XCTAssertEqual(sut.errorLabel.text, "Email is required")
}
func test_successfulLogin_navigatesToHome() {
// Arrange
mockAuthService.loginResult = .success(User(id: "1", name: "Test"))
let mockNavigator = MockNavigator()
sut.navigator = mockNavigator
sut.emailTextField.text = "test@example.com"
sut.passwordTextField.text = "password"
// Act
sut.loginButton.sendActions(for: .touchUpInside)
// Assert
XCTAssertTrue(mockNavigator.didNavigateToHome)
}
}
func test_dataProcessing_performance() {
let largeDataSet = generateLargeDataSet(count: 10000)
measure {
_ = sut.processData(largeDataSet)
}
}
func test_dataProcessing_performanceWithOptions() {
let options = XCTMeasureOptions()
options.iterationCount = 10
measure(options: options) {
_ = sut.processData(generateLargeDataSet(count: 5000))
}
}
func test_memoryUsage_withLargeDataSet() {
let options = XCTMeasureOptions()
options.iterationCount = 5
measure(metrics: [XCTMemoryMetric()], options: options) {
autoreleasepool {
let data = sut.loadLargeDataSet()
sut.processData(data)
}
}
}
func test_cpuUsage_duringOperation() {
measure(metrics: [XCTCPUMetric()]) {
sut.performCPUIntensiveOperation()
}
}
func test_emailValidation_withVariousInputs() {
let testCases: [(email: String, isValid: Bool)] = [
("valid@example.com", true),
("user.name@domain.co.uk", true),
("invalid.email", false),
("", false),
("@example.com", false),
("test@", false),
("test@.com", false),
("test@domain", false)
]
for testCase in testCases {
let result = sut.isValidEmail(testCase.email)
XCTAssertEqual(
result,
testCase.isValid,
"Failed for email: '\(testCase.email)' - expected \(testCase.isValid), got \(result)"
)
}
}
// Using XCTestCase subclass for cleaner parameterized tests
class EmailValidationTests: XCTestCase {
struct TestCase {
let input: String
let expected: Bool
let file: StaticString
let line: UInt
init(_ input: String, _ expected: Bool,
file: StaticString = #file, line: UInt = #line) {
self.input = input
self.expected = expected
self.file = file
self.line = line
}
}
func test_isValidEmail() {
let testCases = [
TestCase("test@example.com", true),
TestCase("invalid", false),
TestCase("", false)
]
for testCase in testCases {
let result = EmailValidator.isValid(testCase.input)
XCTAssertEqual(result, testCase.expected,
file: testCase.file, line: testCase.line)
}
}
}
class LoginUITests: XCTestCase {
var app: XCUIApplication!
override func setUpWithError() throws {
continueAfterFailure = false
app = XCUIApplication()
app.launchArguments = ["--uitesting"]
app.launch()
}
func test_loginFlow_withValidCredentials_showsHomeScreen() {
// Navigate to login
let loginButton = app.buttons["LoginButton"]
XCTAssertTrue(loginButton.waitForExistence(timeout: 5))
// Enter credentials
let emailField = app.textFields["EmailTextField"]
emailField.tap()
emailField.typeText("test@example.com")
let passwordField = app.secureTextFields["PasswordTextField"]
passwordField.tap()
passwordField.typeText("password123")
// Tap login
loginButton.tap()
// Verify home screen
let homeTitle = app.staticTexts["Welcome"]
XCTAssertTrue(homeTitle.waitForExistence(timeout: 10))
}
func test_loginFlow_withInvalidCredentials_showsError() {
let emailField = app.textFields["EmailTextField"]
emailField.tap()
emailField.typeText("wrong@example.com")
let passwordField = app.secureTextFields["PasswordTextField"]
passwordField.tap()
passwordField.typeText("wrongpassword")
app.buttons["LoginButton"].tap()
let errorLabel = app.staticTexts["ErrorLabel"]
XCTAssertTrue(errorLabel.waitForExistence(timeout: 5))
XCTAssertEqual(errorLabel.label, "Invalid credentials")
}
}
test_scheme_configuration:
unit_tests:
targets: ["YourAppTests"]
coverage: true
parallel: true
ui_tests:
targets: ["YourAppUITests"]
coverage: false
parallel: false
launch_arguments: ["--uitesting", "--reset-state"]
integration_tests:
targets: ["YourAppIntegrationTests"]
coverage: true
parallel: false
{
"configurations" : [
{
"name" : "Unit Tests",
"options" : {
"targetForVariableExpansion" : { "target" : { "name" : "YourApp" } }
}
}
],
"defaultOptions" : {
"codeCoverage" : true,
"testTimeoutsEnabled" : true,
"defaultTestExecutionTimeAllowance" : 60
},
"testTargets" : [
{ "target" : { "name" : "YourAppTests" } }
],
"version" : 1
}
test_methodName_condition_expectedResultWeekly Installs
66
Repository
GitHub Stars
3
First Seen
Jan 29, 2026
Security Audits
Gen Agent Trust HubPassSocketPassSnykPass
Installed on
codex65
github-copilot65
opencode65
gemini-cli64
claude-code63
cursor63
测试策略完整指南:单元/集成/E2E测试金字塔与自动化实践
11,200 周安装
agent-xlsx:AI代理专用XLSX命令行工具,快速读取Excel数据并输出JSON/CSV/Markdown
420 周安装
Next.js 最佳实践指南:App Router 开发原则、性能优化与项目结构
420 周安装
Bash 脚本专业开发指南:自动化、安全与最佳实践
426 周安装
React Native 动画教程:Reanimated 3、手势处理与布局动画实战指南
427 周安装
Mapbox搜索集成指南:从需求分析到生产部署的完整工作流程
429 周安装
PostHog Analytics Skill:产品分析、事件跟踪与功能标志的完整解决方案
430 周安装