Android 14 正式版适配笔记(二)— 针对Android14或更高版本应用的变更

Android 14(UPSIDE_DOWN_CAKE)在10月份正式发布了,又需要进行新一轮的适配了。

每一个新版本的变更中,适配都分为两种,一种是不论开发时是否将targetSdkVersion更改为为最新版,只要App运行在Android 14的手机上都得适配。另一种是开发时将targetSdkVersion更改为最新版本,才需要适配。本文主要介绍适配针对targetSdkVersion升级为34的应用的变更。

官方文档

针对targetSdkVersion为34的应用

核心功能

必须设置前台服务类型

本节以定位前台服务为例。

1. 必须配置foregroundServiceType

所有的前台服务必须配置foregroundServiceType,可以在AndroidManifest中通过android:foregroundServiceType属性配置,也可以在使用ServiceCompat.startForeground()启动服务时配置。

  • AndroidManifest中配置android:foregroundServiceType
xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

    <application
        ......
        >
        
        ......

        <service
            android:name=".androidapi.targetsdk.ExampleLocationServices"
            android:exported="false"
            android:foregroundServiceType="location" />
    </application>
</manifest>
  • 通过ServiceCompat.startForeground()配置。
kotlin 复制代码
class ExampleLocationServices : Service() {

    private val locationListener = LocationListener { location ->
        // 获取当前定位
    }

    override fun onCreate() {
        super.onCreate()
        val notification = NotificationCompat.Builder(this, "example_notification_channel")
            .setSmallIcon(R.drawable.notification)
            .setContentTitle("foreground services notification")
            .setContentText("test foreground services notification")
            .build()
        ServiceCompat.startForeground(this,this.hashCode(),notification,ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION)
        getSystemService(LocationManager::class.java).run {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
                // 结合多种数据源(传感器、定位)提供定位信息
                requestLocationUpdates(LocationManager.FUSED_PROVIDER, 2000, 0f, locationListener)
            } else {
                if (isProviderEnabled(LocationManager.GPS_PROVIDER)) {
                    // 使用GPS提供定位信息
                    requestLocationUpdates(LocationManager.GPS_PROVIDER, 2000, 0f, locationListener)
                } else {
                    // 使用网络提供定位信息
                    requestLocationUpdates(LocationManager.NETWORK_PROVIDER, 2000, 0f, locationListener)
                }
            }
        }
    }

    override fun onBind(intent: Intent?): IBinder? {
        return null
    }

    override fun onDestroy() {
        getSystemService(LocationManager::class.java).removeUpdates(locationListener)
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
            stopForeground(STOP_FOREGROUND_REMOVE)
        }else{
            stopForeground(true)
        }
        super.onDestroy()
    }
}

如果没有在AndroidManifest中配置android:foregroundServiceType属性,也没有通过ServiceCompat.startForeground()方法而是使用Services.startForeground()方法启动服务,系统会抛出MissingForegroundServiceTypeException

2. 必须声明所选前台服务类型对应的权限

必须在AndroidManifest中声明所选前台服务类型对应的权限,这些权限都是普通权限,声明后默认授予。

  • AndroidManifest中声明location前台服务权限。
xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

    <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
    <uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />

    <application
        ......
        >
        
        ......

        <service
            android:name=".androidapi.targetsdk.ExampleLocationServices"
            android:exported="false"
            android:foregroundServiceType="location" />
    </application>
</manifest>

如果在没有声明对应权限的情况下启动了前台服务,系统会抛出SecurityException

3. 检测运行时权限

不同类型的前台服务可能会使用一些需要权限的API,例如location前台服务所用的定位API需要定位权限,所以在启动前台服务时务必先获取该类型使用的API所需的权限。

kotlin 复制代码
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

    <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
    <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
    
    <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
    <uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />

    <application
        ......
        >
        
        ......

        <service
            android:name=".androidapi.targetsdk.ExampleLocationServices"
            android:exported="false"
            android:foregroundServiceType="location" />
    </application>
</manifest>

class TargetSdk14AdapterExampleActivity : AppCompatActivity() {

    private lateinit var binding: LayoutTargetSdk14AdapterExampleActivityBinding

    private lateinit var notificationManager: NotificationManagerCompat
    private val exampleNotificationChannel = "example_notification_channel"

    private val requestMultiplePermissionLauncher =
        registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions: Map<String, Boolean> ->
            val noGrantedPermissions = ArrayList<String>()
            permissions.entries.forEach {
                if (!it.value) {
                    noGrantedPermissions.add(it.key)
                }
            }
            if (noGrantedPermissions.isEmpty()) {
                // 申请权限通过,可以启动定位前台服务
                startService(Intent(this, ExampleLocationServices::class.java))
            } else {
                //未同意授权
                noGrantedPermissions.forEach {
                    if (!shouldShowRequestPermissionRationale(it)) {
                        //用户拒绝权限并且系统不再弹出请求权限的弹窗
                        //这时需要我们自己处理,比如自定义弹窗告知用户为何必须要申请这个权限
                    }
                }
            }
        }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = LayoutTargetSdk14AdapterExampleActivityBinding.inflate(layoutInflater)
        setContentView(binding.root)
        binding.includeTitle.tvTitle.text = "Adapt Android 14"

        notificationManager = NotificationManagerCompat.from(this)
        createNotificationChannel()

        binding.btnForegroundServices.setOnClickListener {
            if (ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
                ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
            ) {
                startService(Intent(this, ExampleLocationServices::class.java))
            } else {
                requestMultiplePermissionLauncher.launch(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION))
            }
        }
    }

    private fun createNotificationChannel() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            // 创建通知渠道
            val applicationInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
                packageManager.getApplicationInfo(packageName, PackageManager.ApplicationInfoFlags.of(0))
            } else {
                packageManager.getApplicationInfo(packageName, 0)
            }
            val exampleChannel = NotificationChannel(exampleNotificationChannel, "${getText(applicationInfo.labelRes)} Notification Channel", NotificationManager.IMPORTANCE_DEFAULT).apply {
                description = "The description of this notification channel"
            }
            notificationManager.createNotificationChannel(exampleChannel)
        }
    }
}

如果在没有获取到前台服务类型对应的运行时权限的情况下启动了前台服务,系统会抛出SecurityException

4. 前台服务类型

可选择的前台服务类型如下:

可以一次声明一个或者多个组合。如果上述类型不满足需求,建议改为使用WorkManagerJobServices

蓝牙API权限调整

当targetSdk设置为34时,BluetoothAdapter.getProfileConnectionState()方法需要BLUETOOTH_CONNECT权限。

如果要使用BluetoothAdapter.getProfileConnectionState()方法,确保在AndroidManifest中声明BLUETOOTH_CONNECT权限,并在执行BluetoothAdapter.getProfileConnectionState()方法前检测用户是否授予了BLUETOOTH_CONNECT权限。

示例代码如下:

  • AndroidManifest中声明BLUETOOTH_CONNECT权限。
xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

    <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
    
</manifest>
  • 执行BluetoothAdapter.getProfileConnectionState()方法前检测用户是否授予了BLUETOOTH_CONNECT权限。
kotlin 复制代码
class TargetSdk14AdapterExampleActivity : AppCompatActivity() {

    private lateinit var binding: LayoutTargetSdk14AdapterExampleActivityBinding

    private var requestPermissionName = ""
    private val requestSinglePermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted: Boolean ->
        if (granted) { 
            //同意授权,调用getProfileConnectionState方法
            getSystemService(BluetoothManager::class.java).adapter.getProfileConnectionState(BluetoothProfile.A2DP)
        } else {
            //未同意授权
            if (!shouldShowRequestPermissionRationale(requestPermissionName)) {
                //用户拒绝权限并且系统不再弹出请求权限的弹窗
                //这时需要我们自己处理,比如自定义弹窗告知用户为何必须要申请这个权限
            }
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = LayoutTargetSdk14AdapterExampleActivityBinding.inflate(layoutInflater)
        setContentView(binding.root)
        binding.includeTitle.tvTitle.text = "Adapt Android 14"
        binding.btnBluetoothApi.setOnClickListener {
            if (ActivityCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED) {
                getSystemService(BluetoothManager::class.java).adapter.getProfileConnectionState(BluetoothProfile.A2DP)
            } else {
                requestPermissionName = Manifest.permission.BLUETOOTH_CONNECT
                requestSinglePermissionLauncher.launch(requestPermissionName)
            }
        }
    }
}

如果在未获得BLUETOOTH_CONNECT权限的情况下直接调用BluetoothAdapter.getProfileConnectionState()方法,系统会抛出SecurityException

JobScheduler行为变更

  • 过长的执行时间会导致ANR。

当targetSdk设置为34时,如果JobServicesonStartJobonStopJob方法在主线程允许的执行时间内没有结束,系统会触发ANR并显示对应的错误消息。

举个例子,在JobServicesonStartJob中模拟耗时操作,代码如下:

kotlin 复制代码
class ExampleJobServices : JobService() {

    private var startTime = 0L
    private var currentTime = 0L

    override fun onStartJob(params: JobParameters?): Boolean {
        startTime = System.currentTimeMillis()
        currentTime = System.currentTimeMillis()
        while (currentTime - startTime <= 10 * 1000) {
            currentTime = System.currentTimeMillis()
        }
        return true
    }

    override fun onStopJob(params: JobParameters?): Boolean {
        return false
    }
}

class TargetSdk14AdapterExampleActivity : AppCompatActivity() {

    private lateinit var binding: LayoutTargetSdk14AdapterExampleActivityBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = LayoutTargetSdk14AdapterExampleActivityBinding.inflate(layoutInflater)
        setContentView(binding.root)
        binding.includeTitle.tvTitle.text = "Adapt Android 14"

        binding.btnJobServices.setOnClickListener {
            getSystemService(JobScheduler::class.java).run {
                schedule(JobInfo.Builder(this.hashCode(), ComponentName(this@TargetSdk14AdapterExampleActivity,ExampleJobServices::class.java))
                    .setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY)
                    .build())
            }
        }
    }
}

效果如图:

  • 配置可执行任务的网路情况的约束条件需要对应权限。

当targetSdk设置为34时,如果没有在AndroidManifest中声明ACCESS_NETWORK_STATE权限,使用JobInfo.Builder.setRequiredNetworkTypeJobInfo.Builder.setRequiredNetwork配置可执行任务的网路情况的约束条件时,系统会抛出SecurityException

安全

限制隐式IntentPendingIntent

当targetSdk设置为34时,通过隐式Intent或隐式Intent创建的PendingIntent只能打开设置了android:exported="true"的组件,如果android:exported属性值为false,系统会抛出异常。

举个例子,通过隐式Intent打开ExampleIntentActivity,代码如下:

AndroidManifest

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

    <application
        ......
        >

        <activity
            android:name=".androidapi.targetsdk.ExampleIntentActivity"
            android:exported="false">
            <intent-filter>
                <action android:name="com.chenyihong.exampledemo.EXAMPLE_INTENT"/>
                <category android:name="android.intent.category.DEFAULT" />
            </intent-filter>
        </activity>
    </application>
</manifest>

示例页面

kotlin 复制代码
class TargetSdk14AdapterExampleActivity : AppCompatActivity() {

    private lateinit var binding: LayoutTargetSdk14AdapterExampleActivityBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = LayoutTargetSdk14AdapterExampleActivityBinding.inflate(layoutInflater)
        setContentView(binding.root)
        binding.includeTitle.tvTitle.text = "Adapt Android 14"

        binding.btnIntent.setOnClickListener {
            startActivity(Intent("com.chenyihong.exampledemo.EXAMPLE_INTENT"))
        }
    }
}

通过Intent("com.chenyihong.exampledemo.EXAMPLE_INTENT")打开ExampleIntentActivity时,系统抛出如下异常:

如果需要打开android:exported="false"Activity,改为使用显示Intent,改动代码如下:

kotlin 复制代码
class TargetSdk14AdapterExampleActivity : AppCompatActivity() {

    private lateinit var binding: LayoutTargetSdk14AdapterExampleActivityBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = LayoutTargetSdk14AdapterExampleActivityBinding.inflate(layoutInflater)
        setContentView(binding.root)
        binding.includeTitle.tvTitle.text = "Adapt Android 14"

        binding.btnIntent.setOnClickListener {
            startActivity(Intent("com.chenyihong.exampledemo.EXAMPLE_INTENT").apply {
                setPackage(packageName)
            })
        }
    }
}

效果如图:

通过Context注册的广播接收者必须设置是否导出

当targetSdk设置为34时,通过Context注册接收自定义广播的广播接收者时必须设置是否导出。通过Context注册接收系统广播的广播接收者时不用设置是否导出。

举个例子,注册一个接收自定义Action的广播接收者

kotlin 复制代码
class TargetSdk14AdapterExampleActivity : AppCompatActivity() {

    private lateinit var binding: LayoutTargetSdk14AdapterExampleActivityBinding

    private val EXAMPLE_ACTION_LOGIN="com.chenyihong.exampledemo.LOGIN"
    private val exampleBroadcastReceiver = object : BroadcastReceiver() {
        override fun onReceive(context: Context?, intent: Intent?) {
            when (intent?.action) {
                EXAMPLE_ACTION_LOGIN -> {

                }
            }
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = LayoutTargetSdk14AdapterExampleActivityBinding.inflate(layoutInflater)
        setContentView(binding.root)
        binding.includeTitle.tvTitle.text = "Adapt Android 14"

        binding.btnBroadcast.setOnClickListener {
            registerReceiver(exampleBroadcastReceiver, IntentFilter().apply {
                addAction(EXAMPLE_ACTION_LOGIN)
            })
        }
    }
}

系统会抛出SecurityException异常,如图:

正确注册方式代码如下:

kotlin 复制代码
class TargetSdk14AdapterExampleActivity : AppCompatActivity() {

    private lateinit var binding: LayoutTargetSdk14AdapterExampleActivityBinding

    private val EXAMPLE_ACTION_LOGIN="com.chenyihong.exampledemo.LOGIN"
    private val exampleBroadcastReceiver = object : BroadcastReceiver() {
        override fun onReceive(context: Context?, intent: Intent?) {
            when (intent?.action) {
                EXAMPLE_ACTION_LOGIN -> {

                }
            }
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = LayoutTargetSdk14AdapterExampleActivityBinding.inflate(layoutInflater)
        setContentView(binding.root)
        binding.includeTitle.tvTitle.text = "Adapt Android 14"

        binding.btnBroadcast.setOnClickListener {
            // 需要接收系统广播或其他应用广播使用ContextCompat.RECEIVER_EXPORTED
            // 否则使用ContextCompat.RECEIVER_NOT_EXPORTED
            ContextCompat.registerReceiver(this, exampleBroadcastReceiver, IntentFilter().apply {
                addAction(EXAMPLE_ACTION_LOGIN)
            }, ContextCompat.RECEIVER_NOT_EXPORTED)
        }
    }
}

动态加载代码方式调整

当targetSdk设置为34时,必须将所有动态加载的文件标记为只读,否则系统会抛出异常。

详细内容

限制在后台启动Activity

当targetSdk设置为34时,系统新增了对从后台启动Activity行为的限制。

kotlin 复制代码
val activityOptions = ActivityOptions.makeBasic().apply {
    pendingIntentBackgroundActivityStartMode = ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED
}
PendingIntent.getActivity(this, 0, Intent(this, TargetActivity::class.java), PendingIntent.FLAG_IMMUTABLE, activityOptions.toBundle())
  • 当一个可见应用使用 bindService() 绑定另一个应用在后台运行的服务时,如果想要将后台启动Activity的权限授予被绑定服务,需要在bindService()方法中传入BIND_ALLOW_ACTIVITY_STARTS
scss 复制代码
bindService(serviceIntent, serviceConnection, Context.BIND_ALLOW_ACTIVITY_STARTS);

压缩路径遍历漏洞优化

当targetSdk设置为34时,为了防止Zip路径遍历的漏洞,使用ZipFile(String)ZipInputStream.getNextEntry()时,如果路径以".."或者"/"开头,系统会抛出ZipException

测试了一下,在Assets中存放一个test.zip文件然后将其移动到内部存储空间中并命名为testZipFile.zip,通过testZipFile.zip的相对路径来实例化ZipFileZipInputStream,代码如下:

kotlin 复制代码
class TargetSdk14AdapterExampleActivity : AppCompatActivity() {

    private lateinit var binding: LayoutTargetSdk14AdapterExampleActivityBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = LayoutTargetSdk14AdapterExampleActivityBinding.inflate(layoutInflater)
        setContentView(binding.root)
        binding.includeTitle.tvTitle.text = "Adapt Android 14"

        binding.btnUnzip.setOnClickListener {
            val targetZipFileParent = if (Environment.MEDIA_MOUNTED == Environment.getExternalStorageState()) {
                File(getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), packageName)
            } else {
                File(filesDir, packageName)
            }
            val testZipFile = File(targetZipFileParent, "testZipFile.zip")
            if (!testZipFile.exists()) {
                val inputStream = assets.open("test.zip")
                val fileOutputStream = FileOutputStream(testZipFile)
                val buffer = ByteArray(1024)
                try {
                    var length: Int
                    while (inputStream.read(buffer).also { length = it } != -1) {
                        fileOutputStream.write(buffer, 0, length)
                    }
                    inputStream.close()
                    fileOutputStream.close()
                } catch (e: IOException) {
                    e.printStackTrace()
                }
            } else {
                val zipFile = ZipFile(testZipFile.absolutePath)

//                val zipInputStream = ZipInputStream(FileInputStream(testZipFile.absolutePath))
//                val nextEntry = zipInputStream.nextEntry
            }
        }
    }
}

testZipFile.zip的相对路径为:

bash 复制代码
/storage/emulated/0/Android/data/com.chenyihong.exampledemo/files/Download/com.chenyihong.exampledemo/testZipFile.zip

结果App并不会崩溃。

MediaProjection行为变更

当targetSdk设置为34时,使用MediaProjection进行屏幕捕获或录制时,下列情况会导致系统抛出异常。

  • 调用MediaProjection.createVirtualDisplay()前不注册MediaProjection.Callback回调,示例代码如下:
kotlin 复制代码
// 错误代码
mediaProjection?.createVirtualDisplay("ScreenCapture", resources.displayMetrics.widthPixels, resources.displayMetrics.heightPixels, resources.displayMetrics.density.toInt(), DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, null, null, null)

// 正确代码
mediaProjection?.registerCallback(object : MediaProjection.Callback() {
    override fun onStop() {
        super.onStop()
        // 释放资源
    }
}, null)
mediaProjection?.createVirtualDisplay("ScreenCapture", resources.displayMetrics.widthPixels, resources.displayMetrics.heightPixels, resources.displayMetrics.density.toInt(), DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, null, null, null)

执行错误代码,系统会抛出IllegalStateException,如下:

  • 使用同一个MediaProjection对象多次调用createVirtualDisplay方法,示例代码如下:
kotlin 复制代码
class TargetSdk14AdapterExampleActivity : AppCompatActivity() {

    private lateinit var binding: LayoutTargetSdk14AdapterExampleActivityBinding

    private lateinit var notificationManager: NotificationManagerCompat
    private val exampleNotificationChannel = "example_notification_channel"

    private lateinit var mediaProjectionManager: MediaProjectionManager
    private var mediaProjection: MediaProjection? = null
    private val requestMediaProjectionLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { activityResult ->
        activityResult.data?.let {
            mediaProjection = mediaProjectionManager.getMediaProjection(activityResult.resultCode, it)
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = LayoutTargetSdk14AdapterExampleActivityBinding.inflate(layoutInflater)
        setContentView(binding.root)
        binding.includeTitle.tvTitle.text = "Adapt Android 14"

        notificationManager = NotificationManagerCompat.from(this)
        createNotificationChannel()

        mediaProjectionManager = getSystemService(MediaProjectionManager::class.java)

        binding.btnMediaProjection.setOnClickListener {
            startService(Intent(this, ExampleMediaProjectionServices::class.java))
            if (mediaProjection == null) {
                requestMediaProjectionLauncher.launch(mediaProjectionManager.createScreenCaptureIntent())
            }
            mediaProjection?.createVirtualDisplay("ScreenCapture", resources.displayMetrics.widthPixels, resources.displayMetrics.heightPixels, resources.displayMetrics.density.toInt(), DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, null,
                    object : VirtualDisplay.Callback() {
                        override fun onPaused() {
                            super.onPaused()
                            // 暂停
                        }


                        override fun onResumed() {
                            super.onResumed()
                            // 恢复
                        }

                        override fun onStopped() {
                            super.onStopped()
                            // 停止,此时可以释放资源
                        }
                    }, null)
        }
    }
}

判断MediaProjection对象为空则创建,不为空就重复使用该对象。系统会抛出SecurityException,如下:

通常来说,这种方式可以避免重复创建对象,是不错的做法。但是,为了避免App在仅获得用户一次同意之后,就无限制的使用同一个MediaProjection来获取屏幕内容,系统限制了通过一个MediaProjection只能调用一次createVirtualDisplay

相关推荐
從南走到北27 分钟前
JAVA国际版同城跑腿源码快递代取帮买帮送同城服务源码支持Android+IOS+H5
android·java·ios·微信小程序
2501_915918411 小时前
如何解析iOS崩溃日志:从获取到符号化分析
android·ios·小程序·https·uni-app·iphone·webview
Entropless2 小时前
OkHttp 深度解析(一) : 从一次完整请求看 OkHttp 整体架构
android·okhttp
v***91302 小时前
Spring+Quartz实现定时任务的配置方法
android·前端·后端
wilsend2 小时前
Android Studio 2024版新建java项目和配置环境下载加速
android
兰琛3 小时前
Android Compose展示PDF文件
android·pdf
走在路上的菜鸟3 小时前
Android学Dart学习笔记第四节 基本类型
android·笔记·学习
百锦再4 小时前
第21章 构建命令行工具
android·java·图像处理·python·计算机视觉·rust·django
skyhh5 小时前
Android Studio 最新版汉化
android·ide·android studio
路人甲ing..5 小时前
Android Studio 快速的制作一个可以在 手机上跑的app
android·java·linux·智能手机·android studio