为 Compose MultiPlatform 添加 C/C++ 支持(3):实战 Desktop、Android、iOS 调用同一个 C/C++ 代码

news2024/11/27 2:14:01

theme: serene-rose

前言

在本系列的前两篇文章中我们已经学会了如何在 kotlin native 平台(iOS)使用 cinterop 调用 C/C++ 代码。以及在 jvm 平台(Android、Desktop)使用 jni 调用 C/C++ 代码,并且知道了如何自动编译 Android 端使用的 jni 代码给 Desktop 使用。

那么,我们还犹豫什么呢,是时候把它们都组合在一起,完成真正的 Compose MultiPlatform 全平台调用 C/C++ 了。

在本文中我们将以我去年使用 Compose 写的安卓端的一个简单的小游戏 demo 举例。(终于换项目举例了,哈哈哈)

项目地址:life-game-compose 。

准备工作

在正式开始之前,我们需要先简单的了解一下这个项目,可以看我之前写的这两篇介绍文章:基于 jetpack compose,使用MVI架构+自定义布局实现的康威生命游戏 、使用compose实现康威生命游戏之二:我是如何将计算速度缩减将近十倍的 。

简单介绍一下就是这是个模拟"细胞演化"的小游戏,随着时间的推移,其中的细胞会因为周围的环境(周围细胞数量)而死亡或继续存活。

而在我这个 demo 中,关于细胞状态的逻辑运算使用的是 C 语言写的,因为这部分是动画的帧间计算,对运算速度的要求比较高,使用 C 来实现速度将大幅提升。

当然,这只是我去年测试时的情况,今年当我重新测试时,发现其实在一般设备上直接使用 kotlin 运算的速度和调用 C 运算的速度实际上相差无几,甚至使用 kotlin 比调用 C 更快。

这并不是因为 kotlin 速度真的比 C 快,只是因为如果想要调用 C 的话,需要首先将 kotlin 的数据类型转为 C 中可用的数据类型,时间大多数耗在了数据类型转换上。说句不好听的,有转数据的这个功夫,我直接拿 kotlin 算都已经算出结果来了。

也许你也会说,那我不转数据可以吗?

欸,还真可以。只是对于我们这篇文章要讲的情况不可以,因为我们想要实现的是同一套 C 代码,提供给不同的平台使用,显然 cinterop 和 jni 对于 kotlin 的数据类型与 C 之间的数据类型映射不一样,因此我们为了实现通用性,只能在将数据传递给 C 之前先转为通用的数据类型。

(以上表述比较片面,也不太准确,后文会有详细的解释)

上面说了这么多,只是想叠个 buff ,那就是我们这里使用的这个项目用于举例并不是很恰当,因为这反而会让程序的性能下降。

但是用于理解如何实现在 KMP 中调用 C/C++ 还是非常有用的,权当是抛砖引玉。

最后,上面提到过,原本这个项目只是个 Android 项目,所以在开始我们今天的修改之前,需要先把它移植为 KMP 项目,这里就不再赘述了,因为之前很多篇文章都有说过怎么移植,需要的可以自行翻阅我之前的历史文章。

开始修改

项目整体结构

开始之前,我们先来看看修改完成之后的项目整体结构,需要重点关注的是图中圈起来的部分:

1.jpg

其中 圈1 是项目的 nativelib 模块,这个模块实际上是一个 Android native library 模块,但是我们这里把核心运算代码直接放到这个模块里面了。

game.h 文件即我们需要的算法的具体实现。

nativelib.cpp 是提供给 jni (Android、Desktop)调用的入口函数。

圈 2 是 Desktop 实现调用上述 C++ 代码编译成的二进制库的 kotlin 代码。

圈 3 是 iOS 使用 cinterop 实现调用上述 C++ 算法的入口函数。

接下来,我们挨个看它们的具体代码和需要注意的地方。

核心算法实现

nativelib 模块下的 game.h 文件是整个项目的细胞状态运算核心代码,所有平台最终都是调用其中的 updateStep(int **board, int len1, int len2) 函数实现对细胞状态的计算,代码如下:

#ifndef LIFEGAME_GAME_H
#define LIFEGAME_GAME_H

/*
* 该算法来源如下:
*
* 作者:Time-Limit
* 链接:https://leetcode.cn/problems/game-of-life/solution/c-wei-yun-suan-yuan-di-cao-zuo-ji-bai-shuang-bai-b/
* 来源:力扣(LeetCode)
* 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
*/
void updateStep(int **board, int len1, int len2) {
    int dx[] = {-1,  0,  1, -1, 1, -1, 0, 1};
    int dy[] = {-1, -1, -1,  0, 0,  1, 1, 1};

    for(int i = 0; i < len1; i++) {
        for(int j = 0 ; j < len2; j++) {
            int sum = 0;
            for(int k = 0; k < 8; k++) {
                int nx = i + dx[k];
                int ny = j + dy[k];
                if(nx >= 0 && nx < len1 && ny >= 0 && ny < len2) {
                    sum += (board[nx][ny]&1); // 只累加最低位
                }
            }
            if(board[i][j] == 1) {
                if(sum == 2 || sum == 3) {
                    board[i][j] |= 2;  // 使用第二个bit标记是否存活
                }
            } else {
                if(sum == 3) {
                    board[i][j] |= 2; // 使用第二个bit标记是否存活
                }
            }
        }
    }
    for(int i = 0; i < len1; i++) {
        for(int j = 0; j < len2 ; j++) {
            board[i][j] >>= 1; //右移一位,用第二bit覆盖第一个bit。
        }
    }
}

#endif //LIFEGAME_GAME_H

可以看到,这是个纯粹的 C 代码,没有参杂任何涉及到平台相关的东西,因此它也是我们的共享代码部分。

具体的运算逻辑这里就不说了,我们只需要知道我们项目中的细胞状态使用一个二维整数数组存放,数组索引即为游戏中对应位置细胞的状态,其中数组内容为 0 时表示该位置细胞已死亡,1 表示该位置细胞处于存活状态。

所以该函数接收三个参数 boardlen1len2 ,其中 board 即表示当前状态的二维数组,该函数会在运算中直接修改该数组的内容;len1len2 分别表示数组的行长度和列长度。

了解了核心运算函数,接下来我们看看各个平台如何使用它。

jvm 使用 jni 调用核心算法

在 Android 和 Desktop 将使用 jni 调用上一小节中的 updateStep 函数。

这里我们的 Android 和 Desktop 虽然编译方式不同,但是依旧可以直接复用同一个入口函数,即 nativelib 模块中的 nativelib.cpp 文件的 Java_com_equationl_nativelib_NativeLib_stepUpdate 函数。

该函数签名如下:

extern "C" JNIEXPORT jobjectArray JNICALL
Java_com_equationl_nativelib_NativeLib_stepUpdate(
        JNIEnv* env,
        jobject,
        jobjectArray lifeList
        )

可以看到,该函数接收 3 个参数: JNIEnv* envjobjectjobjectArray lifeList ,其中前两个是 jni 的固定参数,最后一个 jobjectArray 类型的参数 lifeList 才是我们实际需要传递的参数,该函数返回的数据类型也是一个 jobjectArray

接下来,我们看下在 kotlin 中调用这个函数的签名:

external fun stepUpdate(lifeList: Array<IntArray>): Array<IntArray>

可以看到,C 中的 jobjectArray 被映射为了 kotlin 中的 Array<IntArray> 类型。

事实上,jobjectArray 并不是 C 的类型,而是 jni 定义的一个自定义数据类型:

class _jobject {};
class _jarray : public _jobject {};
class _jobjectArray : public _jarray {};
typedef _jobjectArray*  jobjectArray;

而我们的核心运算代码使用的是纯粹的 C 类型,所以这里就需要对数据类型进行一个转换。

我们来看 Java_com_equationl_nativelib_NativeLib_stepUpdate 函数的完整代码:

#include <jni.h>
#include <string>

#include <valarray>

#include "game.h"


extern "C" JNIEXPORT jobjectArray JNICALL
Java_com_equationl_nativelib_NativeLib_stepUpdate(
        JNIEnv* env,
        jobject,
        jobjectArray lifeList
        ) {

	// jni 数据转为 c 数据

    int len1 = env -> GetArrayLength(lifeList);
    auto dim =  (jintArray)env->GetObjectArrayElement(lifeList, 0);
    int len2 = env -> GetArrayLength(dim);
    int **board;
    board = new int *[len1];
    for(int i=0; i<len1; ++i){
        auto oneDim = (jintArray)env->GetObjectArrayElement(lifeList, i);
        jint *element = env->GetIntArrayElements(oneDim, JNI_FALSE);
        board[i] = new int [len2];
        for(int j=0; j<len2; ++j) {
            board[i][j]= element[j];
        }
        // 释放数组
        env->ReleaseIntArrayElements(oneDim, element, JNI_ABORT);
        // 释放引用
        env->DeleteLocalRef(oneDim);
    }


    // 实际的处理逻辑
    updateStep(board, len1, len2);


    // C 数据转回 jni 数据
    jclass cls = env->FindClass("[I");
    jintArray iniVal = env->NewIntArray(len2);
    jobjectArray result = env->NewObjectArray(len1, cls, iniVal);

    for (int i = 0; i < len1; i++)
    {
        jintArray inner = env->NewIntArray(len2);
        env->SetIntArrayRegion(inner, 0, len2, (jint*)board[i]);
        env->SetObjectArrayElement(result, i, inner);
        env->DeleteLocalRef(inner);
    }

    return result;
}

可以看到,在这个代码中,仅仅只有一句 updateStep(board, len1, len2); 是真正的运算代码,而剩余的代码都只是在做数据转换。

其实这里如果我们不是为了能够复用运算逻辑代码,我们完全不需要转换数据,直接在运算时使用 jni 自定义的数据类型就可以了。

我们来看这里的转换代码,有一点需要额外注意的是,在开头将 jobjectArray 转为 int **board 时,有两行我添加了注释的代码:

// 释放数组
// ① 模式 0 : 刷新 Java 数组 , 释放 C/C++ 数组
// ② 模式 1 ( JNI_COMMIT ) : 刷新 Java 数组 , 不释放 C/C ++ 数组
// ③ 模式 2 ( JNI_ABORT ) : 不刷新 Java 数组 , 释放 C/C++ 数组
env->ReleaseIntArrayElements(oneDim, element, JNI_ABORT);
// 释放引用
env->DeleteLocalRef(oneDim);

这两行代码简单理解就是在我们已经完成了将 kotlin 中传过来的 jobjectArray lifeList 复制到了 C 的 int **board 中后就释放掉 lifeList 的内存。

这里一开始我不小心把释放内存的代码写到了复制完成之前,也就是写成了:

env->ReleaseIntArrayElements(oneDim, element, JNI_ABORT);
env->DeleteLocalRef(oneDim);
board[i] = new int [len2];
for(int j=0; j<len2; ++j) {
    board[i][j]= element[j];
}

而我在写这段代码时使用的是 Windows 系统,所以我当时并没有发现不妥,运行代码也没有报错,运行结果也符合预期。

只是当我在 macOS 上测试时却发现,无论我传值是什么,返回结果始终为全是 0 的数组。

为此我还调试了很久,最终在 B 大佬的帮助下才发现原来是我把释放内存写在了复制完成之前。

那么问题来了,为什么同样的代码在 Windows 上运行却没有任何问题?

我们猜测可能是 Windows 和 macOS 的内存回收策略不同,在 macOS 回收更为激进,所以在我调用释放内存后,它的内存就被立即回收了,而 Windows 回收没有这么激进,所以甚至能允许我复制完了都还没有被回收。

记住这个内存回收的问题,在后面我们还会被这个坑一次。

kotlin native 使用 cinterop 调用核心算法

在 iOS 中我们将使用 cinterop 调用核心运算函数 updateStep()

在正式开始之前,我们插个题外话,我们先写一个更简单的 demo 来演示一下上文中提到过的内存回收策略问题的坑。

看下面的代码,

C:

#ifndef LIB2_H_INCLUDED
#define LIB2_H_INCLUDED

int** get_message(int** name, int row, int col) {
    for (int i = 0; i < row; i++) {
        for (int j = 0; j < col; j++) {
            printf("current value from c: name[%d][%d]=%d\n", i, j, name[i][j]);
        }
    }

    // 随便改一个值
    name[1][1] = 114514;
    return name;
}

#endif

kt:

@OptIn(ExperimentalForeignApi::class)
fun main() {
    println("Hello, Kotlin/Native!")
    val list1 = listOf(intArrayOf(0, 1, 2), intArrayOf(4, 5, 6))

    val row = list1.size
    val col = list1[0].size

    val passList  = list1.map { it.pin() }

    val list2 = passList.map { it.addressOf(0) }

    val newList = get_message(list2.toCValues(), row, col)

    for (i in 0 until row) {
        for (j in 0 until col) {
            println("current value from kotlin: result[$i][$j] = ${newList?.get(i)?.get(j)}")
        }
    }
}

无奖竞猜,将上述代码在 Desktop native 中运行,输出结果是什么?

哈哈,不卖关子了,在 Windows 上运行输出如下:

2.jpg

在 macOS 中输出如下:

4.png

显然,和我们上文说的内存回收策略有关。

我们先来看以上的 kt 代码,为了把 kt 的数据传递给 C ,我们使用:

val passList  = list1.map { it.pin() }

val list2 = passList.map { it.addressOf(0) }

val newList = get_message(list2.toCValues(), row, col)

首先使用 pin() 函数将 kt 的 Array 中的 IntArray 地址固定以方便传递给 C 使用,然后使用 addressOf(0) 获取到 IntArray 第一个元素的地址,最后使用 toCValues() 将其转为指针引用传递给 C 的 get_message() 函数。

咋一看好像没有问题是吧?

那么,我们来看看文档中关于 toCValues() 的解释:

3.jpg

重点在于第一段最后一句 In this case the sequence is passed “by value”, i.e., the C function receives the pointer to the temporary copy of that sequence, which is valid only until the function returns.

换句话说就是,我们在上面代码中传递给 C 的数据只是一个拷贝的“副本”,并且这个副本数据的有效期只有函数的运行时间这么短,只要函数运行结束,这个“副本”就会被销毁。

这就不难解释为什么在 macOS 上这段代码运行结果会是这样,至于为什么同样的代码在 Windwos 上运行没有出现问题,我想还是和上一节说的原因一样,就是 Windows 和 macOS 的内存回收策略不同,在 Windows 上内存回收没有 macOS 激进,所以得以在数据本应该已经被回收了还能继续使用。

也就是说,其实我们上面代码的写法是错误的,虽然可以在 Windows 上运行,但是也是极其不可靠的,保不准数据量大时、内存紧张时、甚至在不知道的情况下随机就会出现问题。

那么,怎么解决这个问题呢?根据文档,我们需要使用 nativeHeap.alloc() 或者 nativeHeap.allocArray() 在 kotlin 代码中事先分配一段内存,然后将该内存指针传给 C 使用,此时这个内存就不会被自动释放,需要我们手动释放 nativeHeap.free()

当然,也可以直接使用 memScoped { ... } 语句,在该语句中分配的内存会自动在该语句结束时释放。

现在,让我们回到我们的项目中来。

首先看看 iOS 调用核心运算函数的入口 sahred 模块下的 iosMain 包中的 /nativeinterop/cinterop/nativelib.h 文件。

在该文件使用 #include "../../../../../../nativelib/src/main/cpp/game.h" 引入了 nativelib 模块的核心运算文件 game.h

然后定义了入口函数: int* update(int** board, int row, int col, int* newList)

可以看到不同于 jni 的入口函数,它多了几个参数:rowcolnewList

rowcolboard 的行和列数量,因为这里传递给 C 的数组是指针的形式,所以不好确定长度,索性直接从 kotlin 传过来,而 newList 则是我们在 kotlin 中分配的内存地址的指针,用于将运算完成的结果存入。

这里我将返回类型写为了 int* 其实这里可以不用返回值,在 kotlin 直接使用传给它的 newList 指针即可,但是我只是为了保持一致性,所以把 newList 又返回回去了。

另外,这里我的返回值 newList 不使用二维数组而是使用一维数组是因为返回二维数组有点麻烦,索性转成一维来返回了。

查看 nativelib.h 函数的完整代码如下:

#include "../../../../../../nativelib/src/main/cpp/game.h"

#ifndef LIB2_H_GAME
#define LIB2_H_GAME

int* update(int** board, int row, int col, int* newList) {
    updateStep(board, row, col);

    // 将结果转为一维数组传回
    for (int i = 0; i < row; i++) {
        for (int j = 0; j < col; j++) {
            int value = board[i][j];
            int index = i * col + j;
            newList[index] = value;
        }
    }
    return newList;
}

#endif

对了,上述代码还有个问题,就是我在将二维数组转为一维时直接遍历了整个数组来转,这样效率非常低,完全可以直接使用 memcpy批量复制内存数据,这样会快的多。

接下来,我们来看一下 iOS 端调用这个函数的 kotlin 代码,我们在 iosMain 包中定义了一个函数 stepUpdateNative

@OptIn(ExperimentalForeignApi::class, ExperimentalForeignApi::class)
fun stepUpdateNative(sourceData: Array<IntArray>): Array<IntArray> {

    val row = sourceData.size
    val col = sourceData[0].size

    val list1 = sourceData.map { it.pin() }
    val passList = list1.map { it.addressOf(0) }
    val result = mutableListOf<IntArray>()

    memScoped {
        val arg = allocArray<IntVar>(row * col)
        val resultNative = update(passList.toCValues(), row, col, arg)
        for (i in 0 until row) {
            val line = IntArray(col)
            for (j in 0 until col) {
                val index = i * col + j
                line[j] = resultNative!![index]
                // println("current value from kotlin: result[$index] = ${resultNative?.get(index)}")
            }
            result.add(line)
        }
    }

    return result.toTypedArray()
}

代码很简单,重点在于中间:

memScoped {
    val arg = allocArray<IntVar>(row * col)
    val resultNative = update(passList.toCValues(), row, col, arg)
}

我们在 memScoped 语句中使用 allocArray<IntVar>(row * col) 分配了一块类型为 IntVar 长度为 row * col 的数组空间,然后将其作为 C 函数 update 的参数传入。

返回值 resultNative 即 C 函数运算结束后得到的一维数组结果,所以我们在后面遍历了一遍将其转回二维数组:

for (i in 0 until row) {
    val line = IntArray(col)
    for (j in 0 until col) {
        val index = i * col + j
        line[j] = resultNative!![index]
        // println("current value from kotlin: result[$index] = ${resultNative?.get(index)}")
    }
    result.add(line)
}

简单总结一下

通过上面两个小节的讲解,相信读者也看出问题来了,这时候如果我再说调用 C 的运算速度实际上比直接使用 kotlin 还慢读者应该也恍然大悟知道什么原因了吧。

其实并不是 C 本身慢,而在于我为了在调用 C 的同时复用 C 代码,做了大量的数据转换工作,而每次数据转换的代价都是极其昂贵的,这就导致运算时间反而相比于直接使用 kt 计算还要慢了。

这也是我说的使用这个项目举例是不恰当的原因,因为这就相当于为了这碗醋还特意去大张旗鼓的包了顿饺子一样。

实际上,这系列文章只是为了说明如何在 kmp 中调用 C 代码而不需要每个平台都单独写一套适配代码。

或许在我举的这个例子中小题大做了,但是如果是调用第三方的优秀 C 项目,例如 FFmpeg ,那就是收益远大于付出。

不管怎么说,现在我们已经在所有平台中复用了同一套 C 代码,并且编写好了相应的调用函数,接下来只需要按照我前两篇文章的步骤依次将其接入我们的 KMP 项目中即可。

文章中有没有说到的地方各位读者也可以直接看我的项目代码:life-game-compose

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

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

Leetcode—389.找不同【简单】

2023每日刷题&#xff08;五十五&#xff09; Leetcode—389.找不同 实现代码 char findTheDifference(char* s, char* t) {int len strlen(s);int len2 len 1;int a[26] {0};int b[26] {0};if(len 0) {return t[0];}for(int i 0; i < len; i) {int idx s[i] - a;…

使用alpine镜像部署go应用时踩的坑

使用alpine镜像部署go应用时踩的坑 关于交叉编译 实际上我在ubuntu的交叉编译出来的exe并不能在alpine上运行&#xff0c;这边采取拉镜像编译复制出来的做法&#xff0c;部署再用干净的alpine 拉取golang:alpine踩坑 在Dockerhub上可以找到&#xff1a; 然而拉取的alpine中…

蓝桥杯-动态规划专题-子数组系列,双指针

目录 一、单词拆分 二、环绕字符串中唯一的子字符串 双指针-三数之和 ArrayList(Arrays.asList(array)) 四、四数之和&#xff08;思路和三数之和一样&#xff0c;只是多了一层循环&#xff09; 一、单词拆分 1.状态表示 dp[i]:到达i位置结尾&#xff0c;能否被dict拆分 …

JVM进程缓存

引言 缓存在日常开发中启动至关重要的作用&#xff0c;由于是存储在内存中&#xff0c;数据的读取速度是非常快的&#xff0c;能大量减少对数据库的访问&#xff0c;减少数据库的压力。我们把缓存分为两类&#xff1a; 分布式缓存&#xff0c;例如Redis&#xff1a; 优点&…

【MySQL】MySQL 在 Centos 7环境安装教程

文章目录 1.卸载不要的环境2.检查系统安装包3.获取mysql官方yum源4.安装mysql yum 源&#xff0c;对比前后yum源5.安装mysql服务6.查看配置文件和数据存储位置7.启动服务和查看启动服务8.登录9.配置my.cnf 1.卸载不要的环境 先检查是否有mariadb存在 ps ajx |grep mariadb如果…

虚拟机VMware安装centos以及配置网络

目录 1、CentOS7的下载2、CentOS7的配置3、CentOS7的安装4、CentOS7的网络配置 4.1、自动获取IP4.2、固定获取IP 5、XShell连接CentO 准备工作&#xff1a;提前下载和安装好VMware。VMware的安装可以参考这一篇文章&#xff1a;VMware15的下载及安装教程。 1、CentOS7的下载 …

sentinel整合nacos配置中心持久化

在网上找了很多的资料&#xff0c;发现sentinel整合nacos持久化的博文和视频大多数都只有改造限流部分的教程&#xff0c;并且都需要修改前端&#xff0c;略显麻烦&#xff0c;至于剩下的熔断、热点流控、授权的更是没有相关的改造教程&#xff0c;最后在知乎的看到一篇文章后让…

Sql server数据库数据查询

请查询学生信息表的所有记录。 答&#xff1a;查询所需的代码如下&#xff1a; USE 学生管理数据库 GO SELECT * FROM 学生信息表 执行结果如下&#xff1a; 查询学生的学号、姓名和性别。 答&#xff1a;查询所需的代码如下&#xff1a; USE 学生管理数据库 GO SELE…

文章解读与仿真程序复现思路——电力系统自动化EI\CSCD\北大核心《考虑移动式储能调度的配电网灾后多源协同孤岛运行策略》

这篇文章的标题表明研究的主题是在配电网发生灾害后&#xff0c;采用一种策略来实现多源协同孤岛运行&#xff0c;并在这个过程中特别考虑了移动式储能的调度。 让我们逐步解读标题的关键词&#xff1a; 考虑移动式储能调度&#xff1a; 文章关注的焦点之一是移动式储能系统的…

持续集成交付CICD:Jenkins流水线实现Nexus制品晋级策略

目录 一、理论 1.开发测试运维环境 二、实验 1.Nexus制品晋级策略 一、理论 1.开发测试运维环境 &#xff08;1&#xff09;环境 1&#xff09;持续集成开发环境&#xff08;DEV: Development Environment&#xff09; 直接通过源代码编译打包&#xff0c;其会跑单元测试…

Vue之数据绑定

在我们Vue当中有两种数据绑定的方法 1.单向绑定 2.双向绑定 让我为大家介绍一下吧&#xff01; 1、单向绑定(v-bind) 数据只能从data流向页面 举个例子&#xff1a; <!DOCTYPE html> <html lang"en"> <head><meta charset"UTF-8"…

Docker Compose(容器编排)——9

目录 什么是 Docker Compose生活案例为什么要 Docker ComposeDocker Compose 的安装Docker Compose 的功能Docker Compose 使用场景Docker Compose 文件&#xff08;docker-compose.yml&#xff09; 文件语法版本文件基本结构及常见指令Docker Compose 命令清单 命令清单如下命…

Java项目-瑞吉外卖Day4

实现文件的上传下载&#xff1a; 前端代码&#xff1a; 对文件的操作就是对流的操作。 上传文件的后端代码&#xff0c;需要注意MultipartFile的名字必须与前端相对&#xff1a; 为文件存储位置进行动态设置&#xff0c;配置application.xml 在CommonController中设置属性读…

【Python网络爬虫入门教程2】成为“Spider Man”的第二课:观察目标网站、代码编写

Python 网络爬虫入门&#xff1a;Spider man的第二课 写在最前面观察目标网站代码编写 第二课总结 写在最前面 有位粉丝希望学习网络爬虫的实战技巧&#xff0c;想尝试搭建自己的爬虫环境&#xff0c;从网上抓取数据。 前面有写一篇博客分享&#xff0c;但是内容感觉太浅显了…

【Linux】使用Bash和GNU Parallel并行解压缩文件

介绍 在本教程中&#xff0c;我们将学习如何使用Bash脚本和GNU Parallel实现高效并行解压缩多个文件。这种方法在处理大量文件时可以显著加快提取过程。 先决条件 确保系统上已安装以下内容&#xff1a; BashGNU Parallel 你可以使用以下命令在不同Linux系统上安装它们&am…

深入理解希尔排序

基本思想 希尔排序(Shells Sort)是插入排序的一种又称“缩小增量排序”&#xff08;Diminishing Increment Sort&#xff09;&#xff0c;是直接插入排序算法的一种更高效的改进版本。希尔排序是非稳定排序算法。 对于n个待排序的数列&#xff0c;取一个小于n的整数gap(gap被…

Shell 常用命令详解-上

Shell 常用命令详解-上 1.目录查阅相关命令2.文件操作相关命令 1.目录查阅相关命令 ll 命令 命令描述&#xff1a;ll命令用于显示指定工作目录下的内容。 命令格式&#xff1a;ll [参数] [目录名]。 参数说明&#xff1a; 参数说明-a显示所有文件及目录&#xff08;包括隐藏文…

基于JavaWeb+SSM+Vue助农扶贫微信小程序系统的设计和实现

基于JavaWebSSMVue助农扶贫微信小程序系统的设计和实现 源码获取入口Lun文目录前言主要技术系统设计功能截图 源码获取入口 Lun文目录 目 录 第一章 绪论 1 1.1 研究背景 1 1.2 研究意义 1 1.3 研究内容 2 第二章 开发环境与技术 3 2.1 JSP技术 3 2.2 MySQL数据库 3 2.3 Java…

探索无监督域自适应,释放语言模型的力量:基于检索增强的情境学习实现知识迁移...

深度学习自然语言处理 原创作者: Xnhyacinth 在自然语言处理&#xff08;NLP&#xff09;领域&#xff0c;如何有效地进行无监督域自适应(Unsupervised Domain Adaptation, UDA) 一直是研究的热点和挑战。无监督域自适应的目标是在目标域无标签的情况下&#xff0c;将源域的知识…

排序算法---选择排序

1.实现流程&#xff1a; 1. 把第一个没有排序过的元素设置为最小值&#xff1b; 2. 遍历每个没有排序过的元素&#xff1b; 3. 如果元素 < 现在的最小值&#xff1b; 4. 将此元素设置成为新的最小值&#xff1b; 5. 将最小值和第一个没有排序过的位置交换 选择排序执行流程…