Android - JNI 开发你所需要知道的基础(1)

  • 非静态成员变量使用: GetXXXField,比如 GetIntField,对于引用类型,比如 String,使用 GetObjectField
  • 对于静态成员变量使用: GetStaticXXXField,比如 GetStaticIntField

在 java 代码中,MainActivity 有两个成员变量:

public class MainActivity extends AppCompatActivity {

String testField = “test1”;

static int staticField = 1;
}

// 1. 获取类 class
jclass clazz = env->GetObjectClass(thiz);

// 2. 获取成员变量 id
jfieldID strFieldId = env->GetFieldID(clazz,“testField”,“Ljava/lang/String;”);
// 3. 根据 id 获取值
jstring jstr = static_cast(env->GetObjectField(thiz, strFieldId));
const char* cStr = env->GetStringUTFChars(jstr,NULL);
LOGE(“获取 MainActivity 的 String field :%s”,cStr);

// 4. 修改 String
jstring newValue = env->NewStringUTF(“新的字符创”);
env-> SetObjectField(thiz,strFieldId,newValue);

// 5. 释放资源
env->ReleaseStringUTFChars(jstr,cStr);
env->DeleteLocalRef(newValue);
env->DeleteLocalRef(clazz);

// 获取静态变量
jfieldID staticIntFieldId = env->GetStaticFieldID(clazz,“staticField”,“I”);
jint staticJavaInt = env->GetStaticIntField(clazz,staticIntFieldId);

GetFieldID 和 GetStaticFieldID 需要三个参数:

  • jclass
  • filed name
  • 类型签名: JNI 使用 jvm 的类型签名
类型签名一览表
TypeSignature Java Type
Zboolean
Bbyte
Cchar
Sshort
Iint
Jlong
Ffloat
Ddouble
Vvoid
L fully-qualified-class;fully-qualified-class
[typetype[]
(arg-types) ret-typemethod type
  • 基本数据类型的比较好理解,不如要获取一个 int ,GetFieldID 需要传入签名就是 I;

  • 如果是一个类,比如 String,签名就是 L+全类名; :Ljava.lang.String;

  • 如果是一个 int array,就要写作 [I

  • 如果要获取一个方法,那么方法的签名是:(参数签名)返回值签名,参数如果是多个,中间不需要加间隔符,比如: | java 方法|JNI 签名| |–|–| |void f (int n); |(I)V| |void f (String s,int n); |(Ljava/lang/String;I)V| |long f (int n, String s, int[] arr); |(ILjava/lang/String;[I)J|

操作 method

操作 method 和 filed 非常相似,先获取 MethodID,然后对应的 CallXXXMethod 方法

Java层返回值方法族本地返回类型NativeType
voidCallVoidMethod()(无)
引用类型CallObjectMethod( )jobect
booleanCallBooleanMethod ( )jboolean
byteCallByteMethod( )jbyte
charCallCharMethod( )jchar
shortCallShortMethod( )jshort
intCallIntMethod( )jint
longCallLongMethod()jlong
floatCallFloatMethod()jfloat
doubleCallDoubleMethod()jdouble

在 java 中我们要想获取 MainActivity 的 className 会这样写:

this.getClass().getName()

可以看到需要先调用 getClass 方法获取 Class 对象,然后调用 Class 对象的 getName 方法,我们来看一下如何在 native 方法中调用:

extern “C” JNIEXPORT jstring JNICALL
Java_com_wangzhen_jnitutorial_MainActivity_stringFromJNI(JNIEnv *env, jobject thiz) {
std::string hello = “Hello from C++”;
// 1. 获取 thiz 的 class,也就是 java 中的 Class 信息
jclass thisclazz = env->GetObjectClass(thiz);
// 2. 根据 Class 获取 getClass 方法的 methodID,第三个参数是签名(params)return
jmethodID mid_getClass = env->GetMethodID(thisclazz, “getClass”, “()Ljava/lang/Class;”);
// 3. 执行 getClass 方法,获得 Class 对象
jobject clazz_instance = env->CallObjectMethod(thiz, mid_getClass);
// 4. 获取 Class 实例
jclass clazz = env->GetObjectClass(clazz_instance);
// 5. 根据 class 的 methodID
jmethodID mid_getName = env->GetMethodID(clazz, “getName”, “()Ljava/lang/String;”);
// 6. 调用 getName 方法
jstring name = static_cast(env->CallObjectMethod(clazz_instance, mid_getName));
LOGE(“class name:%s”, env->GetStringUTFChars(name, 0));

// 7. 释放资源
env->DeleteLocalRef(thisclazz);
env->DeleteLocalRef(clazz);
env->DeleteLocalRef(clazz_instance);
env->DeleteLocalRef(name);

return env->NewStringUTF(hello.c_str());
}

创建对象

首先定义一个 java 类:

public class Person {
private int age;
private String name;

public Person(int age, String name){
this.age = age;
this.name = name;
}

public void print(){
Log.e(“Person”,name + age + “岁了”);
}
}

然后我们再 JNI 中创建一个 Person 并调用它的 print 方法:

// 1. 获取 Class
jclass pClazz = env->FindClass(“com/wangzhen/jnitutorial/Person”);
// 2. 获取构造方法,方法名固定为
jmethodID constructID = env->GetMethodID(pClazz,“”,“(ILjava/lang/String;)V”);
if(constructID == NULL){
return;
}
// 3. 创建一个 Person 对象
jstring name = env->NewStringUTF(“alex”);
jobject person = env->NewObject(pClazz,constructID,1,name);

jmethodID printId = env->GetMethodID(pClazz,“print”,“()V”);
if(printId == NULL){
return;
}
env->CallVoidMethod(person,printId);

// 4. 释放资源
env->DeleteLocalRef(name);
env->DeleteLocalRef(pClazz);
env->DeleteLocalRef(person);

JNI 引用

JNI 分为三种引用:

  • 局部引用(Local Reference),类似 java 中的局部变量
  • 全局引用(Global Reference),类似 java 中的全局变量
  • 弱全局引用(Weak Global Reference),类似 java 中的弱引用

上面的代码片段中最后都会有释放资源的代码,这是 c/c++ 编程的良好习惯,对于不同 JNI 引用有不同的释放方式。

局部引用

创建

JNI 函数返回的所有 Java 对象都是局部引用,比如上面调用的 NewObject/FindClass/NewStringUTF 等等都是局部引用。

释放
  • 自动释放 局部引用在方法调用期间有效,并在方法返回后被 JVM 自动释放。
  • 手动释放
手动释放的场景

有了自动释放之后为什么还需要手动释放呢?主要考虑一下场景:

  • 本机方法访问大型Java对象,从而创建对Java对象的局部引用。然后,本机方法在返回到调用方之前执行附加计算。对大型Java对象的本地引用将防止对该对象进行垃圾收集,即使该对象不再用于计算的其余部分。
  • 本机方法创建大量本地引用,但并非所有本地引用都同时使用。因为 JVM 需要一定的空间来跟踪本地引用,所以创建了太多的本地引用,这可能导致系统内存不足。例如,本机方法循环遍历一个大型对象数组,检索作为本地引用的元素,并在每次迭代时对一个元素进行操作。每次迭代之后,程序员不再需要对数组元素的本地引用。

所以我们应该养成手动释放本地引用的好习惯。

手动释放的方式
  • GetXXX 就必须调用 ReleaseXXX。

在调用 GetStringUTFChars 函数从 JVM 内部获取一个字符串之后,JVM 内部会分配一块新的内存,用于存储源字符串的拷贝,以便本地代码访问和修改。即然有内存分配,用完之后马上释放是一个编程的好习惯。通过调用ReleaseStringUTFChars 函数通知 JVM 这块内存已经不使用了。

  • 对于手动创建的 jclass,jobject 等对象使用 DeleteLocalRef 方法进行释放

全局引用

创建

JNI 允许程序员从局部引用创建全局引用:

static jstring globalStr;
if(globalStr == NULL){
jstring str = env->NewStringUTF(“C++”);
// 从局部变量 str 创建一个全局变量
globalStr = static_cast(env->NewGlobalRef(str));

//局部可以释放,因为有了一个全局引用使用str,局部str也不会使用了
env->DeleteLocalRef(str);
}

释放

全局引用在显式释放之前保持有效,可以通过 DeleteGlobalRef 来手动删除全局引用调用。

弱全局引用

与全局引用类似,弱引用可以跨方法、线程使用。与全局引用不同的是,弱引用不会阻止GC回收它所指向的VM内部的对象

所以在使用弱引用时,必须先检查缓存过的弱引用是指向活动的对象,还是指向一个已经被GC的对象

创建

static jclass globalClazz = NULL;
//对于弱引用 如果引用的对象被回收返回 true,否则为false
//对于局部和全局引用则判断是否引用java的null对象
jboolean isEqual = env->IsSameObject(globalClazz, NULL);
if (globalClazz == NULL || isEqual) {
jclass clazz = env->GetObjectClass(instance);
globalClazz = static_cast(env->NewWeakGlobalRef(clazz));
env->DeleteLocalRef(clazz);
}

释放

删除使用 DeleteWeakGlobalRef

线程相关

局部变量只能在当前线程使用,而全局引用可以跨方法、跨线程使用,直到它被手动释放才会失效。

加载动态库

在 android 中有两种方式加载动态库:

  • System.load(String filename) // 绝对路径
  • system library path // 从 system lib 路径下加载

比如下面代码会报错,在 java.library.path 下找不到 hello

static{
System.loadLibrary(“Hello”);
}

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

可以使用下面代码打印出 java.library.path ,并且吧 hello 拷贝到改路径下:

public static void main(String[] args){
System.out.println(System.getProperty(“java.library.path”));
}

JNI_OnLoad

调用System.loadLibrary()函数时, 内部就会去查找so中的 JNI_OnLoad 函数,如果存在此函数则调用。 JNI_OnLoad 必须返回 JNI 的版本,比如 JNI_VERSION_1_6、JNI_VERSION_1_8。

动态注册

JNI 匹配对应的 java 方法有两种方式:

  • 静态注册: 之前我们使用的 Java_com_wangzhen_jnitutorial_MainActivity_stringFromJNI 来进行与java方法的匹配就是静态注册
  • 动态注册:就是将 java 中的方法在代码中动态的与 JNI 方法对应起来

静态注册的名字需要包名,太长了,可以使用动态注册来缩短方法名。

比如我们再 Java 中有两个 native 方法:

public class MainActivity extends AppCompatActivity {
public native void dynamicJavaFunc1();

public native int dynamicJavaFunc2(int i);
}

在 native 代码中,我们不使用静态注册,而使用动态注册

void dynamicNativeFunc1(){
LOGE(“调用了 dynamicJavaFunc1”);
}
// 如果方法带有参数,前面要加上 JNIEnv *env, jobject thisz
jint dynamicNativeFunc2(JNIEnv *env, jobject thisz,jint i){
LOGE(“调用了 dynamicTest2,参数是:%d”,i);
return 66;
}

// 需要动态注册的方法数组
static const JNINativeMethod methods[] = {
{“dynamicJavaFunc1”,“()V”,(void*)dynamicNativeFunc1},
{“dynamicJavaFunc2”,“(I)I”,(int*)dynamicNativeFunc2},
};
// 需要动态注册native方法的类名
static const char *mClassName = “com/wangzhen/jnitutorial/MainActivity”;

jint JNI_OnLoad(JavaVM* vm, void* reserved){
JNIEnv* env = NULL;
// 1. 获取 JNIEnv,这个地方要注意第一个参数是个二级指针
int result = vm->GetEnv(reinterpret_cast<void **>(&env), JNI_VERSION_1_6);
// 2. 是否获取成功
if(result != JNI_OK){
LOGE(“获取 env 失败”);
return JNI_VERSION_1_6;
}
// 3. 注册方法
jclass classMainActivity = env->FindClass(mClassName);
// sizeof(methods)/ sizeof(JNINativeMethod)
result = env->RegisterNatives(classMainActivity,methods, 2);

if(result != JNI_OK){
LOGE(“注册方法失败”)
return JNI_VERSION_1_2;
}

return JNI_VERSION_1_6;
}

这样我们再 MainActivity 中调用 dynamicJavaFunc1 方法就会调用 native 中的 dynamicNativeFunc1 方法。

native 线程中调用 JNIEnv*

前面介绍过 JNIEnv* 是和线程相关的,那么如果在 c++ 中新建一个线程A,在线程A 中可以直接使用 JNIEnv* 吗? 答案是否定的,如果想在 native 线程中使用 JNIEnv* 需要使用 JVM 的 AttachCurrentThread 方法进行绑定:

JavaVM *_vm;

jint JNI_OnLoad(JavaVM* vm, void* reserved){
_vm = vm;
return JNI_VERSION_1_6;
}

void* threadTask(void* args){
JNIEnv *env;
jint result = _vm->AttachCurrentThread(&env,0);
if (result != JNI_OK){
return 0;
}

// …

// 线程 task 执行完后不要忘记分离
_vm->DetachCurrentThread();
}

extern “C”
JNIEXPORT void JNICALL
Java_com_wangzhen_jnitutorial_MainActivity_nativeThreadTest(JNIEnv *env, jobject thiz) {
pthread_t pid;
pthread_create(&pid,0,threadTask,0);
}

交叉编译

在一个平台上编译出另一个平台上可以执行的二级制文件的过程叫做交叉编译。比如在 MacOS 上编译出 android 上可用的库文件。 如果想要编译出可以在 android 平台上运行的库文件就需要使用 ndk。

两种库文件

linux 平台上的库文件分为两种:

  • 静态库: 编译链接时,把库文件的代码全部加入到可执行文件中,因此生成的文件比较大,但在运行时也就不再需要库文件了,linux中后缀名为”.a”。
  • 动态库: 在编译链接时并没有把库文件的代码加入到可执行文件中,而是在程序执行时由运行时链接文件加载库。linux 中后缀名为”.so”,gcc在编译时默认使用动态库。

Android 原生开发套件 (NDK):这套工具使您能在 Android 应用中使用 C 和 C++ 代码。 CMake:一款外部编译工具,可与 Gradle 搭配使用来编译原生库。如果您只计划使用 ndk-build,则不需要此组件。 LLDB:Android Studio 用于调试原生代码的调试程序。

NDK

原生开发套件 (NDK) 是一套工具,使您能够在 Android 应用中使用 C 和 C++ 代码,并提供众多平台库。 我们可以在 sdk/ndk-bundle 中查看 ndk 的目录结构,下面列举出三个重要的成员:

  • ndk-build: 该 Shell 脚本是 Android NDK 构建系统的起始点,一般在项目中仅仅执行这一个命令就可以编译出对应的动态链接库了。
  • platforms: 该目录包含支持不同 Android 目标版本的头文件和库文件, NDK 构建系统会根据具体的配置来引用指定平台下的头文件和库文件。
  • toolchains: 该目录包含目前 NDK 所支持的不同平台下的交叉编译器 - ARM 、X86、MIPS ,目前比较常用的是 ARM。 // todo ndk-depends.cmd

ndk 为什么要提供多平台呢? 不同的 Android 设备使用不同的 CPU,而不同的 CPU 支持不同的指令集。更具体的内容参考官方文档

使用 ndk 手动编译动态库

在 ndk 目录下的 toolchains 下有多个平台的编译工具,比如在 /arm-linux-androideabi-4.9/prebuilt/darwin-x86_64/bin 下可以找到 arm-linux-androideabi-gcc 执行文件,利用 ndk 的这个 gcc 可以编译出在 android(arm 架构) 上运行的动态库:

arm-linux-androideabi-gcc -fPIC -shared test.c -o libtest.so

参数含义 -fPIC: 产生与位置无关代码 -shared:编译动态库,如果去掉代表静态库 test.c:需要编译的 c 文件 -o:输出 libtest.so:库文件名

独立工具链 版本比较新的 ndk 下已经找不到 gcc 了,如果想用的话需要参考独立工具链。 比如执行 $NDK/build/tools/make_standalone_toolchain.py --arch arm --api 21 --install-dir/$yourDir 可以产生 arm 的独立工具链

$NDK 代表 ndk 的绝对路径, $yourDir 代表输出文件路径

当源文件很多的时候,手动编译既麻烦又容易出错,此时出现了 makefile 编译。

makefile

makefile 就是“自动化编译”:一个工程中的源文件不计数,其按类型、功能、模块分别放在若干个目录中,makefile定义了一系列的规则来指定,哪些文件需要先编译,哪些文件需要后编译,如何进行链接等等操作。 Android 使用 Android.mk 文件来配置 makefile,下面是一个最简单的 Android.mk:

源文件在的位置。宏函数 my-dir 返回当前目录(包含 Android.mk 文件本身的目录)的路径。

LOCAL_PATH := $(call my-dir)

引入其他makefile文件。CLEAR_VARS 变量指向特殊 GNU Makefile,可为您清除许多 LOCAL_XXX 变量

不会清理 LOCAL_PATH 变量

include $(CLEAR_VARS)

指定库名称,如果模块名称的开头已是 lib,则构建系统不会附加额外的前缀 lib;而是按原样采用模块名称,并添加 .so 扩展名。

LOCAL_MODULE := hello

包含要构建到模块中的 C 和/或 C++ 源文件列表 以空格分开

LOCAL_SRC_FILES := hello.c

构建动态库

include $(BUILD_SHARED_LIBRARY)

我们配置好了 Android.mk 文件后如何告诉编译器这是我们的配置文件呢? 这时候需要在 app/build.gradle 文件中进行相关的配置:

apply plugin: ‘com.android.application’

android {
compileSdkVersion 29

defaultConfig {

// 应该将源文件编译成几个 CPU so
externalNativeBuild{
ndkBuild{
abiFilters ‘x86’,‘armeabi-v7a’
}
}
// 需要打包进 apk 几种 so
ndk {
abiFilters ‘x86’,‘armeabi-v7a’
}
}
// 配置 native 构建脚本位置
externalNativeBuild{
ndkBuild{
path “src/main/jni/Android.mk”
}
}
// 指定 ndk 版本
ndkVersion “20.0.5594570”


}

dependencies {
implementation fileTree(dir: “libs”, include: [“*.jar”])

}

Google 推荐开发者使用 cmake 来代替 makefile 进行交叉编译了,makefile 在引入第三方预编译好的 so 的时候会在 android 6.0 版本前后有些差异,比如在 6.0 之前需要手动 System.loadLibrary 第三方 so,在之后则不需要。 关于 makefile 还有很多配置参数,这里不在讲解,更多参考官方文档

在 6.0 以下,System.loadLibrary 不会自动加载 so 内部依赖的 so 在 6.0 以下,System.loadLibrary 会自动加载 so 内部依赖的 so 所以使用 mk 的话需要做版本兼容

cmake

CMake是一个跨平台的构建工具,可以用简单的语句来描述所有平台的安装(编译过程)。能够输出各种各样的makefile或者project文件。Cmake 并不直接建构出最终的软件,而是产生其他工具的脚本(如Makefile ),然后再依这个工具的构建方式使用。 Android Studio利用CMake生成的是ninja,ninja是一个小型的关注速度的构建系统。我们不需要关心ninja的脚本,知道怎么配置cmake就可以了。

CMakeLists.txt

Make的脚本名默认是CMakeLists.txt,当我们用 android studio new project 勾选 include c/c++ 的时候,会默认生成以下文件:

|- app |-- src |— main |---- cpp |----- CMakeLists.txt |----- native-lib.cpp

先来看一下 CMakeLists.txt:

设置 cmake 最小支持版本

cmake_minimum_required(VERSION 3.4.1)

创建一个库

add_library( # 库名称,比如现在会生成 native-lib.so
native-lib

设置是动态库(SHARED)还是静态库(STATIC)

SHARED

设置源文件的相对路径

native-lib.cpp )

搜索并指定预构建库并将路径存储为变量。

NDK中已经有一部分预构建库(比如 log),并且ndk库已经是被配置为cmake搜索路径的一部分

可以不写 直接在 target_link_libraries 写上log

find_library( # 设置路径变量的名称
log-lib

指定要CMake定位的NDK库的名称

log )

指定CMake应链接到目标库的库。你可以链接多个库,例如构建脚本、预构建的第三方库或系统库。

target_link_libraries( # Specifies the target library.
native-lib
${log-lib} )

我们再来看下 gradle 中的配置:

android {
compileSdkVersion 29
buildToolsVersion “29.0.1”
defaultConfig {

testInstrumentationRunner “androidx.test.runner.AndroidJUnitRunner”
// 设置编译版本
externalNativeBuild {
cmake {
abiFilters “armeabi-v7a”,“x86”
}
}
}

// 设置配置文件路径
externalNativeBuild {
cmake {
path “src/main/cpp/CMakeLists.txt”
version “3.10.2”
}
}
}

这样在编译产物中就可以看到两个版本的 so:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

添加多个源文件

比如我们添加一个 extra.h:

#ifndef JNITUTORIAL_EXTRA_H
#define JNITUTORIAL_EXTRA_H
const char * getString(){
return “string from extra”;
}
#endif //JNITUTORIAL_EXTRA_H

然后在 native-lib.cpp 中使用:

#include <jni.h>
#include
#include <android/log.h>
#include “extra.h”
// VA_ARGS 代表… 可变参数
#define LOGE(…) __android_log_print(ANDROID_LOG_ERROR,“JNI”,VA_ARGS);

extern “C” JNIEXPORT jstring JNICALL
Java_com_wangzhen_jnitutorial_MainActivity_stringFromJNI(JNIEnv *env, jobject thiz) {
// std::string hello = “Hello from new C++”;
std::string hello = getString();
return env->NewStringUTF(hello.c_str());
}

源文件已经写好了,这时候要修改一下 CMakeLists.txt:

add_library(
native-lib
SHARED
native-lib.cpp
// 添加 extra.h
extra.h )

#==================================

当然如果源文件非常多,并且可能在不同的文件夹下,像上面明确的引入各个文件就会非常繁琐,此时可以批量引入

如果文件太多,可以批量加载,下面时将 cpp 文件夹下所有的源文件定义成了 SOURCE(后面的源文件使用相对路径)

最后

答应大伙的备战金三银四,大厂面试真题来啦!

这份资料我从春招开始,就会将各博客、论坛。网站上等优质的Android开发中高级面试题收集起来,然后全网寻找最优的解答方案。每一道面试题都是百分百的大厂面经真题+最优解答。包知识脉络 + 诸多细节。
节省大家在网上搜索资料的时间来学习,也可以分享给身边好友一起学习。

《960全网最全Android开发笔记》

《379页Android开发面试宝典》

包含了腾讯、百度、小米、阿里、乐视、美团、58、猎豹、360、新浪、搜狐等一线互联网公司面试被问到的题目。熟悉本文中列出的知识点会大大增加通过前两轮技术面试的几率。

如何使用它?
1.可以通过目录索引直接翻看需要的知识点,查漏补缺。
2.五角星数表示面试问到的频率,代表重要推荐指数

《507页Android开发相关源码解析》

只要是程序员,不管是Java还是Android,如果不去阅读源码,只看API文档,那就只是停留于皮毛,这对我们知识体系的建立和完备以及实战技术的提升都是不利的。

真正最能锻炼能力的便是直接去阅读源码,不仅限于阅读各大系统源码,还包括各种优秀的开源库。

腾讯、字节跳动、阿里、百度等BAT大厂 2020-2021面试真题解析

资料收集不易,如果大家喜欢这篇文章,或者对你有帮助不妨多多点赞转发关注哦。文章会持续更新的。绝对干货!!!

网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。

需要这份系统化学习资料的朋友,可以戳这里获取

一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!

表重要推荐指数

[外链图片转存中…(img-d8wOx64d-1714260831189)]

《507页Android开发相关源码解析》

只要是程序员,不管是Java还是Android,如果不去阅读源码,只看API文档,那就只是停留于皮毛,这对我们知识体系的建立和完备以及实战技术的提升都是不利的。

真正最能锻炼能力的便是直接去阅读源码,不仅限于阅读各大系统源码,还包括各种优秀的开源库。

[外链图片转存中…(img-y3dbdYEB-1714260831190)]

腾讯、字节跳动、阿里、百度等BAT大厂 2020-2021面试真题解析

[外链图片转存中…(img-CeugnyPE-1714260831190)]

资料收集不易,如果大家喜欢这篇文章,或者对你有帮助不妨多多点赞转发关注哦。文章会持续更新的。绝对干货!!!

网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。

需要这份系统化学习资料的朋友,可以戳这里获取

一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值