Android性能优化 - 从SharedPreferences到DataStore

前言

对于android开发者们来说,SharedPreferences已经是一个老生常谈的话题了,之所以还在性能优化这个专栏中再次提到,是因为在实际项目中还是会有很多使用到的地方,同时它也有足够的“坑”,比如常见的主进程阻塞,虽然SharedPreferences 提供了异步操作api apply,但是apply方法依旧有可能造成ANR。

下面我们分析一下异步操作api apply方法的源码:

public void apply() {
    final long startTime = System.currentTimeMillis();

    final MemoryCommitResult mcr = commitToMemory();
    final Runnable awaitCommit = new Runnable() {
            @Override
            public void run() {
                try {
                    mcr.writtenToDiskLatch.await();
                } catch (InterruptedException ignored) {
                }

                if (DEBUG && mcr.wasWritten) {
                    Log.d(TAG, mFile.getName() + ":" + mcr.memoryStateGeneration
                            + " applied after " + (System.currentTimeMillis() - startTime)
                            + " ms");
                }
            }
        };

    QueuedWork.addFinisher(awaitCommit);

    Runnable postWriteRunnable = new Runnable() {
            @Override
            public void run() {
                awaitCommit.run();
                QueuedWork.removeFinisher(awaitCommit);
            }
        };
    // 写入队列
    SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable);

    // Okay to notify the listeners before it's hit disk
    // because the listeners should always get the same
    // SharedPreferences instance back, which has the
    // changes reflected in memory.
    notifyListeners(mcr);
}

通过分析会,我们看到runnable被写入了队列,而这个队列会在handleStopService() 、handlePauseActivity() 、 handleStopActivity() 的时候会一直等待 apply() 方法将数据保存成功,否则会一直等待,从而阻塞主线程造成 ANR。

@Override
public void handlePauseActivity(ActivityClientRecord r, boolean finished, boolean userLeaving,
    int configChanges, PendingTransactionActions pendingActions, String reason) {
    if (userLeaving) {
        performUserLeavingActivity(r);
    }

    r.activity.mConfigChangeFlags |= configChanges;
    performPauseActivity(r, finished, reason, pendingActions);

    // Make sure any pending writes are now committed.
    if (r.isPreHoneycomb()) {
        // 这里就是元凶
        QueuedWork.waitToFinish();
    }
    mSomeActivitiesChanged = true;
}

Google 分析的 SharedPreferences 和 DataStore 的区别的一张图:
在这里插入图片描述

虽然QueuedWork在android 8中有了新的优化,但是实际上依旧有ANR的出现,在低版本的机型上更加出现频繁,所以我们不可能把sp真的逃避掉。

目前业内有很多替代的方案,就是采用MMKV去解决,但是官方并没有采用像mmkv的方式去解决,而是另起炉灶,在jetpack中引入DataStore去替代旧时代的SharedPreferences。

下面我们就分析一下DataStore的事项原理。

走进DataStore

Jetpack DataStore 是一种数据存储解决方案,允许您使用协议缓冲区存储键值对或类型化对象。DataStore 使用 Kotlin 协程和 Flow 以异步、一致的事务方式存储数据。

DataStore 提供两种不同的实现:Preferences DataStore 和 Proto DataStore(基于protocol buffers)。

  • Preferences DataStore:仅使用键存储和访问值数据。此实现不需要预定义的架构,并且不提供类型安全性。
  • Proto DataStore:将数据存储为自定义数据类型的实例。此实现要求您使用协议缓冲区(protobuf - PB协议)定义架构,但它提供类型安全性。

我们这里主要以Preferences DataStore作为分析,同时在kotlin中,datastore采取了flow的良好架构,进行了内部的调度实现,同时也提供了java兼容版本(采用RxJava实现)

1:使用例子

val Context.dataStore : DataStore<Preferences> by preferencesDataStore(“文件名”)

因为datastore需要依靠协程的环境,所以我们可以有以下方式实现读写:

  • 读取数据
CoroutineScope(Dispatchers.Default).launch {
    context.dataStore.data.collect {
        value = it[booleanPreferencesKey(key)] ?: defValue
    }
}
  • 写入数据
CoroutineScope(Dispatchers.IO).launch {
    context.dataStore.edit { settings ->
        settings[booleanPreferencesKey(key) ] = value
    }

}

其中booleanPreferencesKey代表着存入的value是boolean类型,同样的,假设我们需要存入的数据类型是String,相应的key就是通过stringPreferencesKey(key名) 创建。同时因为返回的是flow,我们是需要调用collect这种监听机制去获取数值的改变,如果想要像sp一样采用同步的方式直接获取,官方通过runBlocking进行获取,比如

val exampleData = runBlocking { context.dataStore.data.first() }

2:DataStore实现原理

DataStore提供给了我们非常简洁的api,所以我们也能够很快速的入门使用,但是其中的原理实现,我们是要了解的,因为其创建过程十分简单,我们就从数据更新(context.dataStore.edit)的角度出发,看看DataStore究竟做了什么.

首先我们看到edit方法:

public suspend fun DataStore<Preferences>.edit(
    transform: suspend (MutablePreferences) -> Unit
): Preferences {
    return this.updateData {
        // It's safe to return MutablePreferences since we freeze it in
        // PreferencesDataStore.updateData()
        it.toMutablePreferences().apply { transform(this) }
    }
}

可以看到edit方法是一个suspend的函数,其主要的实现就是依靠updateData方法

interface DataStore<T> 中:

public suspend fun updateData(transform: suspend (t: T) -> T): T

DataStore是有两种实现,我们要看的就是Preferences DataStore的实现,其实现类是

internal class PreferenceDataStore(private val delegate: DataStore<Preferences>) :
    DataStore<Preferences> by delegate {
    override suspend fun updateData(transform: suspend (t: Preferences) -> Preferences):
        Preferences {
            return delegate.updateData {
                val transformed = transform(it)
                // Freeze the preferences since any future mutations will break DataStore. If a user
                // tunnels the value out of DataStore and mutates it, this could be problematic.
                // This is a safe cast, since MutablePreferences is the only implementation of
                // Preferences.
                (transformed as MutablePreferences).freeze()
                transformed
            }
        }
}

可以看到PreferenceDataStore中updateData方法的具体实现其实在delegate中,而这个delegate的创建是在PreferenceDataStoreFactory中,


public fun create(
    corruptionHandler: ReplaceFileCorruptionHandler<Preferences>? = null,
    migrations: List<DataMigration<Preferences>> = listOf(),
    scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
    produceFile: () -> File
): DataStore<Preferences> {
    val delegate = DataStoreFactory.create(
        serializer = PreferencesSerializer,
        corruptionHandler = corruptionHandler,
        migrations = migrations,
        scope = scope
    ) {
       忽略
    }
    return PreferenceDataStore(delegate)
}

而在DataStoreFactory.create方法中:

public fun <T> create(
        serializer: Serializer<T>,
        corruptionHandler: ReplaceFileCorruptionHandler<T>? = null,
        migrations: List<DataMigration<T>> = listOf(),
        scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
        produceFile: () -> File
    ): DataStore<T> =
        SingleProcessDataStore(
            produceFile = produceFile,
            serializer = serializer,
            corruptionHandler = corruptionHandler ?: NoOpCorruptionHandler(),
            initTasksList = listOf(DataMigrationInitializer.getInitializer(migrations)),
            scope = scope
        )
}

DataStoreFactory.create 创建的其实是一个SingleProcessDataStore的对象,SingleProcessDataStore同时也是继承于DataStore,它就是所有DataStore背后的真正的实现者。而它的updateData方法就是解决问题的幕后助力。

override suspend fun updateData(transform: suspend (t: T) -> T): T {
    val ack = CompletableDeferred<T>()
    val currentDownStreamFlowState = downstreamFlow.value

    val updateMsg =
        Message.Update(transform, ack, currentDownStreamFlowState, coroutineContext)

    actor.offer(updateMsg)

    return ack.await()
}

我们可以看到,update方法中,有一个叫 ack的 CompletableDeferred对象,而CompletableDeferred,是继承于Deferred。我们到这里就应该能够猜到了,这个Deferred对象不正是我们协程中常用的异步调用类嘛!它提供了await操作允许我们等待异步的结果。 最后封装好的Message被放入actor.offer(updateMsg) 中,actor是消息处理类对象,它的定义如下

internal class SimpleActor<T>(
    /**
     * The scope in which to consume messages.
     */
    private val scope: CoroutineScope,
    /**
     * Function that will be called when scope is cancelled. Should *not* throw exceptions.
     */
    onComplete: (Throwable?) -> Unit,
    /**
     * Function that will be called for each element when the scope is cancelled. Should *not*
     * throw exceptions.
     */
    onUndeliveredElement: (T, Throwable?) -> Unit,
    /**
     * Function that will be called once for each message.
     *
     * Must *not* throw an exception (other than CancellationException if scope is cancelled).
     */
    private val consumeMessage: suspend (T) -> Unit
) {
    private val messageQueue = Channel<T>(capacity = UNLIMITED)

我们看到,我们所有的消息会被放到一个叫messageQueueChannel对象中,Channel其实就是一个适用于协程信息通信的线程安全的队列。

最后我们看看offer函数干了什么:

.......

do {
    // We don't want to try to consume a new message unless we are still active.
    // If ensureActive throws, the scope is no longer active, so it doesn't
    // matter that we have remaining messages.
    scope.ensureActive()

    consumeMessage(messageQueue.receive())
} while (remainingMessages.decrementAndGet() != 0)
.........

其实就是通过consumeMessage消费了我们的消息。到这里我们再一次回到我们DataStore中的SimpleActor实现对象

private val actor = SimpleActor<Message<T>>(
    scope = scope,
    onComplete = {
        it?.let {
            downstreamFlow.value = Final(it)
        }
        // We expect it to always be non-null but we will leave the alternative as a no-op
        // just in case.

        synchronized(activeFilesLock) {
            activeFiles.remove(file.absolutePath)
        }
    },
    onUndeliveredElement = { msg, ex ->
        if (msg is Message.Update) {
            // TODO(rohitsat): should we instead use scope.ensureActive() to get the original
            //  cancellation cause? Should we instead have something like
            //  UndeliveredElementException?
            msg.ack.completeExceptionally(
                ex ?: CancellationException(
                    "DataStore scope was cancelled before updateData could complete"
                )
            )
        }
    }
) { 
    consumeMessage 实际
    msg ->
    when (msg) {
        is Message.Read -> {
            handleRead(msg)
        }
        is Message.Update -> {
            handleUpdate(msg)
        }
    }
}

可以看到,consumeMessage其实就是以lambada形式展开了,实现的内容也很直观,如果是Message.Update就调用了handleUpdate方法

private suspend fun handleUpdate(update: Message.Update<T>) {

    // 这里就是completeWith调用,也就是回到了外部Deferred的await方法
    update.ack.completeWith(
        runCatching {

            when (val currentState = downstreamFlow.value) {
                is Data -> {
                    // We are already initialized, we just need to perform the update
                    transformAndWrite(update.transform, update.callerContext)
                }

    ...

最后通过了transformAndWrite调用writeData方法,写入数据(FileOutputStream)

internal suspend fun writeData(newData: T) {
    file.createParentDirectories()

    val scratchFile = File(file.absolutePath + SCRATCH_SUFFIX)
    try {
        FileOutputStream(scratchFile).use { stream ->
            serializer.writeTo(newData, UncloseableOutputStream(stream))
            stream.fd.sync()
            // TODO(b/151635324): fsync the directory, otherwise a badly timed crash could
            //  result in reverting to a previous state.
        }

        if (!scratchFile.renameTo(file)) {
            throw IOException(
                "Unable to rename $scratchFile." +
                    "This likely means that there are multiple instances of DataStore " +
                    "for this file. Ensure that you are only creating a single instance of " +
                    "datastore for this file."
            )
        }

至此,我们整个过程就彻底分析完了,读取数据跟写入数据类似,只是最后调用的处理函数不一致罢了(consumeMessage 调用handleRead),同时我们也分析出来handleUpdate的update.ack.completeWith让我们也回到了协程调用完成后的世界。

实战SharedPreferences全局替换成DataStore

分析完DataStore,我们已经有了足够的了解,那么我们就实战SharedPreferences迁移至DataStore了吧!

1:如何将旧sp数据迁移到datastore?

已存在的sp对象数据,我们可以通过以下方法无缝迁移到datastore

dataStore = context.createDataStore( name = preferenceName, migrations = listOf( SharedPreferencesMigration( context, "sp的名称" ) ) )

2:如何实现无侵入替换sp为DataStore?

当然,我们项目中可能会存在很多历史遗留的sp使用,此时用手动替换会容易出错,而且不方便,其次是三方库所用到sp我们也无法手动更改,那么有没有一种方案可以无需对原有项目改动,就可以迁移到DataStore呢?这个时候就是我们的性能优化系列的老朋友,ASM登场啦!

我们来分析一下,怎么把SharedPreferences

val sp = this.getSharedPreferences("test",0)
val editor = sp.edit()
editor.putBoolean("testBoolean",true)
editor.apply()

替换成我们想要的DataStore,不及,我们先看一下这串代码的字节码

LINENUMBER 24 L2
    ALOAD 0
    LDC "test"
    ICONST_0
    INVOKEVIRTUAL com/example/spider/MainActivity.getSharedPreferences (Ljava/lang/String;I)Landroid/content/SharedPreferences;
    ASTORE 2

我们可以看到,我们的字节码中存在ALOAD ASTORE这种依赖于操作数栈环境的指令,就知道不能简单的实现指令替换,而是采用同类替换的方式去现实,即我们可以通过继承于SharedPreferences,在自定义SharedPreferences中实现DataStore的操作,严格来说,这个自定义SharedPreferences,其实就相当于一个壳子了。这种替换方式在Android性能优化-线程监控与线程统一也有使用到。
在这里插入图片描述
我们来看一下自定义的SharedPreferences操作,这里以putBoolean相关操作举例子

class DataPreference(val context: Context,name:String):SharedPreferences {
    val Context.dataStore : DataStore<Preferences> by preferencesDataStore(name)

    override fun getBoolean(key: String, defValue: Boolean): Boolean {
        var value = defValue
        runBlocking {

        }
        runBlocking {
            context.dataStore.data.first {
                value = it[booleanPreferencesKey(key)] ?: defValue
                true
            }
        }
//        CoroutineScope(Dispatchers.Default).launch {
//            context.dataStore.data.collect {
//
//                value = it[booleanPreferencesKey(key)] ?: defValue
//                Log.e("hello","value os $value")
//            }
//        }

        return value
    }

    override fun edit(): SharedPreferences.Editor {
       return DataEditor(context)
    }

    inner class DataEditor(private val context: Context): SharedPreferences.Editor {

        override fun putBoolean(key: String, value: Boolean): SharedPreferences.Editor {
            CoroutineScope(Dispatchers.IO).launch {
                context.dataStore.edit { settings ->
                    settings[booleanPreferencesKey(key) ] = value
                }
            }
            return this
        }

        override fun commit(): Boolean {
            // 空实现即可
        }

        override fun apply() {
           // 空实现即可
        }
    }
}

因为putBoolean中其实就已经把数据存好了,所有我们的commit/apply都可以以空实现的方式替代。同时我们也声明一个扩展函数

fun Context.getDataPreferences(name:String,mode:Int): SharedPreferences {
    return DataPreference(this,name)
}

字节码部分操作也比较简单,我们只需要把原本的 INVOKEVIRTUAL com/example/spider/MainActivity.getSharedPreferences (Ljava/lang/String;I)Landroid/content/SharedPreferences; 指令替换成INVOKESTATIC的StoreTestKt扩展函数getDataPreferences调用即可,同时由于接受的是SharedPreferences类型而不是我们的DataPreference类型,所以需要采用CHECKCAST转换。

static void spToDataStore(
        MethodInsnNode node,
        ClassNode klass,
        MethodNode method
) {
    println("init  ===>  " + node.name+" --"+node.desc + " " + node.owner)

    if (node.name.equals("getSharedPreferences")&&node.desc.equals("(Ljava/lang/String;I)Landroid/content/SharedPreferences;")) {

        MethodInsnNode methodHookNode = new MethodInsnNode(Opcodes.INVOKESTATIC,
                "com/example/spider/StoreTestKt",
                "getDataPreferences",
                "(Landroid/content/Context;Ljava/lang/String;I)Landroid/content/SharedPreferences;",
                false)

        TypeInsnNode typeInsnNode = new TypeInsnNode(Opcodes.CHECKCAST, "android/content/SharedPreferences")
        InsnList insertNodes = new InsnList()
        insertNodes.add(methodHookNode)
        insertNodes.add(typeInsnNode)
        method.instructions.insertBefore(node, insertNodes)
        method.instructions.remove(node)

        println("hook  ===>  " + node.name + " " + node.owner + " " + method.instructions.indexOf(node))
    }

}

3:分析实现方案的“不足”之处以及改进方案

当然,我们这个方案并不是百分比完美的,他也是存在一些缺点的。

editor.apply()
sp.getBoolean

原因是如果采用这种方式apply()后立马取数据,因为我们替换后putBoolean其实是一个异步操作,而我们getBoolean是同步操作,所以就有可能没有拿到最新的数据。但是这个使用姿势本身就是一个不好的使用姿势,同时业内的滴滴开源Booster的sp异步线程commit优化也同样有这个问题。因为put之后立马get不是一个规范写法,所以我们也不会对此多加干预。不过对于我们DataStore替换后来说,也有更加好的解决方式:

CoroutineScope(Dispatchers.Default).launch {
    context.dataStore.data.collect {

        value = it[booleanPreferencesKey(key)] ?: defValue
        Log.e("hello","value os $value")
    }
}

通过flow的异步特性,我们完全可以对value进行collect,调用层通过collect进行数据的收集,就能够做到万无一失啦(虽然也带来了侵入性)

总结

通过分析SP和DataStore的优缺点,以及谷歌官方给出的SP和DataStore的对照图,我们发现DataStore相对于SP的缺做了不少的提升,通过我们分析,也知道使用SP异步存储数据的缺会存在ANR,作为一个开发者,我们必须从Android性能优化方面多家考虑,因此,我觉DataStore替换SP是非常有必要的。

当然也有人会说我们可以使用MMKV,从而更优的优化我们app的性能,别急接下来,我也会专门出一期关于MMKV的讲解,以此来对比三者各自的优缺点和使用场景,让我们在开发时可以进一步灵活选取和使用适合当前开发场景的开发框架。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/202875.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

k8s中批量处理Pod应用的Job和CronJob控制器介绍

目录 一.Job控制器 1.简介 2.Jobs较完整解释 3.示例演示 4.注意&#xff1a;如上例的话&#xff0c;执行“kubectl delete -f myJob.yaml”就可以将job删掉 二.CronJob&#xff08;简写为cj&#xff09; 1.简介 2.CronJob较完整解释 3.案例演示 4.如上例的话&#xf…

Tomcat的安装及其使用

一.下载安装 本文下载的是8.5版本的&#xff0c;下载链接&#xff1a;Apache Tomcat - Welcome! 切记解压缩的目录不要有中文存在。 二.启动Tomcat 在解压缩之后&#xff0c;会有很多文件存在&#xff0c;但是我们只需要在意两个文件&#xff01; webapps 目录 . web applica…

亚马逊产品如何在 TikTok 上推广?

对亚马逊卖家而言&#xff0c;TikTok是提升品牌社交媒体影响力的理想平台。该平台在过去一年中实现了飞速增长&#xff0c;使得营销变得既快捷又有趣&#xff0c;且高效。本文将详细阐述如何在TikTok推广亚马逊产品&#xff0c;并如何策划更强大的品牌营销活动。 各大品牌纷纷…

Anemone库的爬虫程序代码示例

以下是代码&#xff1a; ruby require anemone # 设置代理服务器 Anemone.proxies { http > "", https > "" } # 定义爬取的URL url # 使用Anemone进行爬取 Anemone.crawl(url) do |page| # 使用正则表达式找出所有的视频链接 video_…

Chrome显示分享按钮

分享按钮不见了&#xff01; Chrome://flags Chrome Refresh 2023 Disabled 左上角的标签搜索会到右上角。

Git - cherry-pick

文章目录 前言git资源 前言 本地 Git 仓库有两个分支&#xff0c;分别为 main 和 dev&#xff0c;dev 是 main 在 hash 为 a2 的时候创建的开发分支&#xff1a; 现在需要将 dev 分支中 hash 为 b1 的 commit 单独合并到分支 main 去&#xff1a; 这种将 dev 中部分特定 commi…

Windows系统如何远程控制Realme手机?

realme使用的是realme UI系统。realme UI是realme研发的操作系统&#xff1b;realme UI 1.0基于安卓10系统&#xff0c;realme UI 2.0基于安卓11系统&#xff0c;realme UI 3.0基于安卓12系统。 对于安卓4.0及以上系统的手机&#xff0c;都可以通过软件AirDroid实现远程控制。 …

JavaScript黑科技:简洁有用的一行代码,让你的开发效率飙升!

说在前面 在这篇技术博客中&#xff0c;我们将向你介绍一些令人惊叹的JavaScript黑科技&#xff0c;这些只需一行代码就能实现的简洁而有用的功能&#xff0c;将极大地提升你的开发效率。无论是优化代码、增加交互性&#xff0c;还是实现复杂的逻辑&#xff0c;这些代码片段将成…

echarts图表显示不全

图表显示是显示了&#xff0c;但是没有展示全部&#xff0c;一看控制台div的高度只有1px了&#xff0c;手动修改高度也只是拉伸图表&#xff0c;并没有按规定的尺寸展示 随之开始思考为什么呢 ? ? ? 因为 Echarts 的依赖是惰性的&#xff0c;需要手动设置resize&#xff0…

Android textView 显示: STRING_TOO_LARGE

默认情况下&#xff0c;TextView只能显示大约32K的字符。如果你的字符串超过这个限制&#xff0c;你将收到一个错误&#xff1a;“String too large”。 <string content" ...."/>问题点是&#xff1a;getResource().getString(R.string.content) 得到的是&am…

TableAgent:首个国产可私有部署的企业级Code Interpreter

TableAgent公测地址&#xff1a;https://tableagent.DataCanvas.com 数字化时代&#xff0c;数据分析的重要性犹如空气般无处不在。商业数据分析是数字化管理、智能决策的基础&#xff0c;同时数据分析又是一个专业性极强的工作&#xff0c;描述性分析、诊断性分析、预测性分…

【Azure 架构师学习笔记】- Azure Databricks (1) - 环境搭建

本文属于【Azure 架构师学习笔记】系列。 本文属于【Azure Databricks】系列。 前言 Databricks 已经成为了数据科学的必备工具&#xff0c;今时今日你已经很难抛开它来谈大数据&#xff0c;它常用于做复杂的ETL中的T&#xff0c; 数据分析&#xff0c;数据挖掘等&#xff0c;…

论文学习-Attention Is All You Need

Attention Is All You Need 目前暂时不会用到&#xff0c;大概了解一下即可。 Recurrent model 序列化的计算方式&#xff0c;难以并行&#xff0c;随着序列的增长&#xff0c;以前的记忆会逐渐丢失。而Attention机制可以观察到句子中所有的信息&#xff0c;不受距离影响&…

MySQL中的JOIN与IN:性能对比与最佳实践

文章目录 JOIN与IN的基本介绍JOININ JOIN与IN性能对比使用JOIN的查询使用IN的查询 何时使用JOIN何时使用IN性能优化的其他考虑因素总结 &#x1f389;MySQL中的JOIN与IN&#xff1a;性能对比与最佳实践 ☆* o(≧▽≦)o *☆嗨~我是IT陈寒&#x1f379;✨博客主页&#xff1a;IT陈…

基于单片机设计的激光测距仪(采用XKC-Kl200模块)

一、前言 随着科技的不断进步和应用需求的增加&#xff0c;测距仪成为了许多领域必备的工具之一。传统的测距仪价格昂贵、体积庞大&#xff0c;使用起来不够方便。本项目采用STC89C52单片机作为主控芯片&#xff0c;结合XKC-KL200激光测距模块和LCD1602显示器&#xff0c;实现…

C++输出100以内的素数

以下是一个简单的C程序&#xff0c;用于输出100以内的所有素数&#xff1a; #include <iostream>using namespace std;int main() { int num, i, flag 0; for(num 2; num < 100; num) { flag 0; for(i 2; i < num/2; i) { if(…

市场调研:2023年SLG游戏行业需求及发展前景预测

SLG游戏(SLG游戏)一般指策略游戏&#xff0c;策略游戏是一种以取得各种形式胜利为主题的游戏。这类游戏提供给玩家一个可以动脑筋思考问题来处理较复杂事情的环境&#xff0c;允许玩家自由控制、管理和使用游戏中的人、或事物&#xff0c;通过这种自由的手段以及玩家们开动脑筋…

第72讲:MySQL数据库锁机制剖析:行级锁、间隙锁与临键锁详解及应用指南

文章目录 1.行级锁的概念2.行锁的概念以及基本使用2.1.行锁的概念2.2.常见的SQL语句所对应的行锁类别2.3.行锁的基本使用 3.间隙锁和临键锁的概念以及基本使用3.1.间隙锁和临键锁的概念3.2.间隙锁和临键锁的基本使用 1.行级锁的概念 行级锁指的是&#xff0c;每次操作锁住的是…

WebUI自动化学习(Selenium+Python+Pytest框架)004

接下来&#xff0c;WebUI基础知识最后一篇。 1.下拉框操作 关于下拉框的处理有两种方式 &#xff08;1&#xff09;按普通元素定位 安装普通元素的定位方式来定位下拉框&#xff0c;使用元素的操作方法element.click()方法来操作下拉框内容的选择 &#xff08;2&#xff09…

CSS特效022:小球抛物线效果

CSS常用示例100专栏目录 本专栏记录的是经常使用的CSS示例与技巧&#xff0c;主要包含CSS布局&#xff0c;CSS特效&#xff0c;CSS花边信息三部分内容。其中CSS布局主要是列出一些常用的CSS布局信息点&#xff0c;CSS特效主要是一些动画示例&#xff0c;CSS花边是描述了一些CSS…