Skip to content

设备与系统信息

源:Android Build | iOS UIDevice

获取设备信息是应用的基础需求,用于日志追踪、兼容性判断、用户分析等场景。本文将展示如何跨平台获取设备型号、系统版本、唯一标识等信息。

平台差异对比

平台原生 API可获取信息隐私限制
AndroidBuild + PackageManager设备型号、系统版本、CPU 架构、ANDROID_IDAndroid 10+ 限制设备 ID
iOS/macOSUIDevice + ProcessInfo设备型号、系统版本、IDFV严格限制设备标识符
Desktop (JVM)System.getProperty()OS 名称、版本、CPU 架构无限制
Desktop (Native)Platform API系统信息、硬件信息依赖平台

expect/actual 实现方案

API 核心签名说明

  • expect class DeviceInfo
  • val DeviceInfo.platform: Platform
  • val DeviceInfo.osVersion: String
  • val DeviceInfo.deviceModel: String
  • val DeviceInfo.deviceId: String
  • val DeviceInfo.appVersion: String

标准代码块

kotlin
enum class Platform {
    ANDROID, IOS, DESKTOP
}

expect class DeviceInfo {
    val platform: Platform
    val platformName: String
    val osVersion: String
    val deviceModel: String
    val deviceManufacturer: String
    val deviceId: String
    val appVersion: String
    val appBuildNumber: String
}

// 业务层使用
class AnalyticsService(private val deviceInfo: DeviceInfo) {
    
    fun logAppStart() {
        println("App started on ${deviceInfo.platformName}")
        println("Device: ${deviceInfo.deviceModel}")
        println("OS: ${deviceInfo.osVersion}")
        println("Version: ${deviceInfo.appVersion}")
    }
    
    fun buildUserAgent(): String {
        return "${deviceInfo.platformName}/${deviceInfo.osVersion} " +
               "(${deviceInfo.deviceModel}) " +
               "App/${deviceInfo.appVersion}"
    }
}
kotlin
import android.content.Context
import android.os.Build
import android.provider.Settings

actual class DeviceInfo(private val context: Context) {
    
    actual val platform: Platform = Platform.ANDROID
    
    actual val platformName: String = "Android"
    
    actual val osVersion: String = "${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})"
    
    actual val deviceModel: String = "${Build.MANUFACTURER} ${Build.MODEL}"
    
    actual val deviceManufacturer: String = Build.MANUFACTURER
    
    actual val deviceId: String by lazy {
        Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID) ?: "unknown"
    }
    
    actual val appVersion: String by lazy {
        try {
            val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0)
            packageInfo.versionName ?: "unknown"
        } catch (e: Exception) {
            "unknown"
        }
    }
    
    actual val appBuildNumber: String by lazy {
        try {
            val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0)
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
                packageInfo.longVersionCode.toString()
            } else {
                @Suppress("DEPRECATION")
                packageInfo.versionCode.toString()
            }
        } catch (e: Exception) {
            "unknown"
        }
    }
    
    // 扩展:获取 CPU 架构
    val cpuArchitecture: String = Build.SUPPORTED_ABIS.firstOrNull() ?: "unknown"
    
    // 扩展:是否为平板
    val isTablet: Boolean by lazy {
        val configuration = context.resources.configuration
        val screenLayout = configuration.screenLayout and 
            android.content.res.Configuration.SCREENLAYOUT_SIZE_MASK
        screenLayout >= android.content.res.Configuration.SCREENLAYOUT_SIZE_LARGE
    }
}
kotlin
import platform.Foundation.*
import platform.UIKit.UIDevice

actual class DeviceInfo {
    
    actual val platform: Platform = Platform.IOS
    
    actual val platformName: String = UIDevice.currentDevice.systemName
    
    actual val osVersion: String = UIDevice.currentDevice.systemVersion
    
    actual val deviceModel: String = UIDevice.currentDevice.model
    
    actual val deviceManufacturer: String = "Apple"
    
    actual val deviceId: String by lazy {
        UIDevice.currentDevice.identifierForVendor?.UUIDString ?: "unknown"
    }
    
    actual val appVersion: String by lazy {
        NSBundle.mainBundle.objectForInfoDictionaryKey("CFBundleShortVersionString") as? String ?: "unknown"
    }
    
    actual val appBuildNumber: String by lazy {
        NSBundle.mainBundle.objectForInfoDictionaryKey("CFBundleVersion") as? String ?: "unknown"
    }
    
    // 扩展:获取设备具体型号(如 iPhone 14 Pro)
    val deviceModelDetailed: String by lazy {
        var sysinfo = utsname()
        uname(&sysinfo)
        val modelCode = NSString.stringWithCString(
            CPointer.narrow(sysinfo.machine.ptr),
            NSUTF8StringEncoding
        ) ?: "unknown"
        mapDeviceModel(modelCode as String)
    }
    
    private fun mapDeviceModel(code: String): String {
        return when {
            code.contains("iPhone15,2") -> "iPhone 14 Pro"
            code.contains("iPhone15,3") -> "iPhone 14 Pro Max"
            code.contains("iPhone14") -> "iPhone 13"
            code.contains("iPad13") -> "iPad Pro"
            code.contains("x86_64") || code.contains("i386") -> "Simulator"
            else -> code
        }
    }
}
kotlin
actual class DeviceInfo {
    
    actual val platform: Platform = Platform.DESKTOP
    
    actual val platformName: String = System.getProperty("os.name") ?: "Unknown"
    
    actual val osVersion: String = System.getProperty("os.version") ?: "Unknown"
    
    actual val deviceModel: String = buildString {
        append(System.getProperty("os.name"))
        append(" ")
        append(System.getProperty("os.arch"))
    }
    
    actual val deviceManufacturer: String = "Generic"
    
    actual val deviceId: String by lazy {
        // 使用 MAC 地址或主机名作为设备标识
        try {
            java.net.InetAddress.getLocalHost().hostName
        } catch (e: Exception) {
            "desktop-${System.currentTimeMillis()}"
        }
    }
    
    actual val appVersion: String = "1.0.0" // 可从 build.gradle 读取
    
    actual val appBuildNumber: String = "1"
    
    // 扩展:JVM 信息
    val javaVersion: String = System.getProperty("java.version") ?: "Unknown"
    val javaVendor: String = System.getProperty("java.vendor") ?: "Unknown"
}

代码封装示例

以下是带缓存和格式化的完整封装:

kotlin
// commonMain
data class DeviceEnvironment(
    val platform: Platform,
    val osName: String,
    val osVersion: String,
    val deviceModel: String,
    val appVersion: String,
    val deviceId: String,
    val locale: String
) {
    fun toUserAgent(): String {
        return "$osName/$osVersion ($deviceModel) App/$appVersion"
    }
    
    fun toAnalyticsMap(): Map<String, String> {
        return mapOf(
            "platform" to platform.name,
            "os_name" to osName,
            "os_version" to osVersion,
            "device_model" to deviceModel,
            "app_version" to appVersion,
            "device_id" to deviceId,
            "locale" to locale
        )
    }
}

class DeviceEnvironmentProvider(private val deviceInfo: DeviceInfo) {
    
    private var cachedEnvironment: DeviceEnvironment? = null
    
    fun getEnvironment(): DeviceEnvironment {
        return cachedEnvironment ?: DeviceEnvironment(
            platform = deviceInfo.platform,
            osName = deviceInfo.platformName,
            osVersion = deviceInfo.osVersion,
            deviceModel = deviceInfo.deviceModel,
            appVersion = deviceInfo.appVersion,
            deviceId = deviceInfo.deviceId,
            locale = getCurrentLocale()
        ).also { cachedEnvironment = it }
    }
    
    private fun getCurrentLocale(): String {
        // 由各平台实现
        return "en_US"
    }
}

第三方库推荐

KMP 设备信息库对比

库名维护状态功能完整性推荐度
自实现-基础功能⭐⭐⭐⭐
Napier活跃日志+设备信息⭐⭐⭐
Kermit活跃日志+设备信息⭐⭐⭐

推荐方案

设备信息的获取逻辑相对简单,建议自实现以保持轻量级。若项目已集成日志库,可复用其设备信息采集模块。

依赖补充

自实现无需额外依赖

设备信息获取仅依赖平台标准库,无需添加第三方依赖。

Android 扩展权限(可选)

如需获取更多设备信息(如 IMEI),需在 AndroidManifest.xml 添加权限:

xml
<!-- 仅在必要时使用,Android 10+ 普通应用无法获取 -->
<uses-permission android:name="android.permission.READ_PHONE_STATE" />

实战坑点

Android ANDROID_ID 不稳定

设备 ID 重置

ANDROID_ID 在以下情况会改变:

  • 应用卸载后重装
  • 设备恢复出厂设置
  • 应用签名变更(Android 8.0+)

解决方案:
生成 UUID 并持久化到本地存储作为稳定设备标识:

kotlin
class StableDeviceId(private val settings: Settings) {
    
    fun getDeviceId(): String {
        var id = settings.getString("stable_device_id", "")
        if (id.isEmpty()) {
            id = UUID.randomUUID().toString()
            settings.putString("stable_device_id", id)
        }
        return id
    }
}

iOS identifierForVendor 为 nil

IDFV 获取失败

在极少数情况下(如设备未正确初始化),identifierForVendor 可能返回 nil

解决方案:
提供降级方案:

kotlin
actual val deviceId: String by lazy {
    UIDevice.currentDevice.identifierForVendor?.UUIDString 
        ?: generateFallbackId()
}

private fun generateFallbackId(): String {
    val uuid = NSUUID.UUID().UUIDString
    // 可选:持久化到 UserDefaults
    return uuid
}

Desktop 主机名获取异常

网络未配置

在某些 Desktop 环境(如 Docker 容器),InetAddress.getLocalHost() 可能抛出异常。

解决方案:

kotlin
actual val deviceId: String by lazy {
    try {
        java.net.InetAddress.getLocalHost().hostName
    } catch (e: Exception) {
        // 降级为 MAC 地址或生成 UUID
        generateMacBasedId() ?: UUID.randomUUID().toString()
    }
}

版本号格式不一致

::: caution 跨平台版本号差异 Android versionName 可以是任意字符串(如 "v1.2.3-beta"),iOS 必须符合语义化版本。 :::

统一方案:
在 commonMain 定义版本号解析器:

kotlin
data class AppVersion(
    val major: Int,
    val minor: Int,
    val patch: Int,
    val suffix: String = ""
) {
    companion object {
        fun parse(versionString: String): AppVersion {
            val regex = Regex("""(\d+)\.(\d+)\.(\d+)(.*)""")
            val match = regex.matchEntire(versionString) ?: return AppVersion(0, 0, 0)
            return AppVersion(
                major = match.groupValues[1].toInt(),
                minor = match.groupValues[2].toInt(),
                patch = match.groupValues[3].toInt(),
                suffix = match.groupValues[4]
            )
        }
    }
}

隐私合规风险

GDPR 与设备标识符

在欧盟地区,设备标识符属于个人数据,必须:

  1. 在隐私政策中披露
  2. 获得用户同意
  3. 提供删除/重置机制

合规建议:

kotlin
class ConsentManager(private val settings: Settings) {
    
    fun hasUserConsent(): Boolean {
        return settings.getBoolean("analytics_consent", false)
    }
    
    fun requestConsent() {
        // 显示同意对话框
    }
    
    fun getDeviceIdIfConsented(deviceInfo: DeviceInfo): String? {
        return if (hasUserConsent()) {
            deviceInfo.deviceId
        } else {
            null
        }
    }
}