Xposed-Hook
配置 Xposed 模块的 AndroidManifest.xml:
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"package="your.package.name"><applicationandroid:label="Your Xposed Module Name"android:icon="@mipmap/ic_launcher"><meta-dataandroid:name="xposedmodule"android:value="true" /><meta-dataandroid:name="xposeddescription"android:value="这是一个用于监控字符串操作的 Xposed 模块" /><meta-dataandroid:name="xposedminversion"android:value="53" /><meta-dataandroid:name="xposedscope"android:resource="@array/xposed_scope" /></application>
</manifest>
在app/src/main/assets创建一个xposed_init文件 。
xposed_init 文件是 Xposed 模块必需的一个配置文件,它用来指定模块的入口类。这个文件需要包含你的 Xposed 模块的主类的完整类名(包含包名):
your.package.name.MainHook
app/build.gradle配置一下:
dependencies {implementation 'androidx.appcompat:appcompat:1.6.1'implementation 'com.google.android.material:material:1.9.0'// Xposed Framework APIcompileOnly 'de.robv.android.xposed:api:82'compileOnly 'de.robv.android.xposed:api:82:sources'// 如果需要使用 LSPosed API(可选)// compileOnly 'org.lsposed.hiddenapibypass:hiddenapibypass:4.3'
}
LoadPackageParam (简称 lpparam) 是 Xposed 框架中的一个重要参数类,它包含了被加载的应用程序的相关信息。
LoadPackageParam 是 XC_LoadPackage 的一个内部类,以下是它的所有成员变量:
public static final class LoadPackageParam extends XCallback.Param {/** 应用的包名 */public String packageName;/** 进程的名称 */public String processName;/** 应用的 ClassLoader */public ClassLoader classLoader;/** 应用的 Application 对象 */public ApplicationInfo appInfo;/** 是否是第一次加载 */public boolean isFirstApplication;/** 系统服务的进程名(如果是系统服务) */public String[] initiatingPackages;/** 系统服务的进程名(如果是系统服务) */public String initiatingPackage;
}
// 获取当前加载的应用包名String pkgName = lpparam.packageName;// 例如: "com.android.chrome"
// 获取当前进程名String procName = lpparam.processName;// 可能是: "com.android.chrome"// 或者: "com.android.chrome:sandbox"
类加载器
- 它负责找到并加载你需要的工具(类)
// 相当于说"帮我找到这个工具"
Class<?> targetClass = lpparam.classLoader.loadClass("com.example.Target");
// 找到后就可以使用这个类了
XposedHelpers.findAndHookMethod(targetClass, "方法名", ...);
// 获取应用的类加载器ClassLoader loader = lpparam.classLoader;// 用于加载目标应用中的类Class<?> targetClass = loader.loadClass("com.example.Target");
// 获取应用的信息ApplicationInfo info = lpparam.appInfo;// 可以获取很多应用相关信息String sourceDir = info.sourceDir; // APK 路径String nativeLibDir = info.nativeLibraryDir; // native库路径int targetSdkVersion = info.targetSdkVersion; // 目标SDK版本
XposedHelpers
查找和 Hook 方法
// 1. 基本的方法 Hook
XposedHelpers.findAndHookMethod("com.example.Class", // 类名lpparam.classLoader, // 类加载器"methodName", // 方法名String.class, // 参数类型1int.class, // 参数类型2new XC_MethodHook() {@Overrideprotected void beforeHookedMethod(MethodHookParam param) {// 方法执行前的处理}@Overrideprotected void afterHookedMethod(MethodHookParam param) {// 方法执行后的处理}}
);// 2. Hook 构造方法
XposedHelpers.findAndHookConstructor(targetClass, // 类String.class, // 参数类型new XC_MethodHook() { ... }
);
获取/设置字段值
// 获取字段值
Object fieldValue = XposedHelpers.getObjectField(object, "fieldName");
int intValue = XposedHelpers.getIntField(object, "fieldName");
String stringValue = XposedHelpers.getStaticObjectField(class, "fieldName");// 设置字段值
XposedHelpers.setObjectField(object, "fieldName", newValue);
XposedHelpers.setIntField(object, "fieldName", 123);
XposedHelpers.setStaticObjectField(class, "fieldName", newValue);
调用方法
// 调用实例方法
Object result = XposedHelpers.callMethod(object, "methodName", arg1, arg2);// 调用静态方法
Object result = XposedHelpers.callStaticMethod(class, "methodName", arg1, arg2);
创建新实例
// 创建对象实例
Object newInstance = XposedHelpers.newInstance(class);
Object newInstance = XposedHelpers.newInstance(class, "构造参数1", "构造参数2");
查找类
// 查找类
Class<?> class = XposedHelpers.findClass("com.example.Class", lpparam.classLoader);
Hook Api
GravityBox
GravityBox 是一个非常著名的 Xposed 模块,它是一个系统级的调整工具箱。
-
更改赋予 Android 终端硬件按钮的功能
-
修改状态栏的外观和显示的选项
-
改变可以在手机或平板电脑屏幕上显示的亮度(这个是积极的,尤其是最小的,以节省电池)
-
直接应用程序分配给设备的触摸按钮
-
管理终端 RAM 的使用,了解此应用程序的消耗
Xposed Hook
- choose: 查找某个类的所有实例对象
- enumerateClassLoaders: 查找所有的类加载器
Java.choose() 和 Java.enumerateClassLoaders() 是 Frida 中两个不同的 API,它们的用途不同:
// 用于查找指定类的所有实例
Java.choose("com.example.TargetClass", {onMatch: function(instance) {// 每找到一个实例就会调用一次console.log("找到实例:", instance);console.log("实例字段值:", instance.fieldName);},onComplete: function() {// 搜索完成时调用console.log("搜索完成");}
});
// 用于列举所有的类加载器
Java.enumerateClassLoaders({onMatch: function(loader) {// 每找到一个类加载器就会调用一次console.log("类加载器:", loader);// 可以尝试用这个加载器加载类try {loader.loadClass("com.example.TargetClass");console.log("这个加载器可以加载目标类");} catch(e) {console.log("这个加载器无法加载目标类");}},onComplete: function() {console.log("搜索完成");}
});
主要区别:
- choose: 查找某个类的所有实例对象
- enumerateClassLoaders: 查找所有的类加载器
简单说:
- 想找对象用 choose
- 想找类加载器用 enumerateClassLoaders
// 1. 最简单的使用方式
Java.perform(function() {// 获取默认的类加载器var targetClass = Java.use("com.example.TargetClass");
});// 2. 当默认加载器找不到类时,可以遍历所有"图书管理员"
Java.enumerateClassLoaders({onMatch: function(loader) {try {// 让每个"管理员"都尝试找这本"书"loader.loadClass("com.example.TargetClass");console.log("找到了!这个管理员可以找到这本书");} catch(e) {console.log("这个管理员找不到这本书");}}
});
让我用更简单的方式解释 IXposedHookLoadPackage:
想象你是一个保安,站在商场门口:
- 每当有人(应用)要进商场时,你都会被通知
- 你可以检查他们的身份(包名),决定要不要对他们做什么
// 你就是这个保安
public class MainHook implements IXposedHookLoadPackage {@Overridepublic void handleLoadPackage(LoadPackageParam lpparam) throws Throwable {// 1. 检查是不是你要找的人if (lpparam.packageName.equals("com.taobao.qianniu")) {// 是千牛应用XposedBridge.log("发现千牛启动了!");// 2. 对这个应用做一些事// 比如:监控它的某个方法XposedHelpers.findAndHookMethod("com.taobao.qianniu.MainActivity", // 类名lpparam.classLoader, // 类加载器"onCreate", // 方法名Bundle.class, // 参数类型new XC_MethodHook() {@Overrideprotected void beforeHookedMethod(MethodHookParam param) {XposedBridge.log("千牛正在启动...");}});}}
}
handleLoadPackage 是 IXposedHookLoadPackage 接口中唯一需要实现的方法。它的作用是:
public class MainHook implements IXposedHookLoadPackage {@Overridepublic void handleLoadPackage(LoadPackageParam lpparam) throws Throwable {// 这个方法会在每个应用启动时被调用// 1. 可以获取应用的包名String packageName = lpparam.packageName;// 2. 可以获取应用的类加载器ClassLoader classLoader = lpparam.classLoader;// 3. 实际使用示例if (packageName.equals("com.taobao.qianniu")) {// 找到目标应用后,就可以开始 Hook 了XposedBridge.log("找到千牛了!");// Hook 示例XposedHelpers.findAndHookMethod("目标类名",classLoader,"方法名",new XC_MethodHook() {@Overrideprotected void beforeHookedMethod(MethodHookParam param) {// 方法执行前的处理}});}}
}
// Xposed 模块中使用 XposedBridge.log
XposedBridge.log("发现目标应用:" + TARGET_PACKAGE); // 这是 Java 代码
// Frida 脚本中使用 console.log
console.log("发现目标应用:" + TARGET_PACKAGE); // 这是 JavaScript 代码
XC_MethodHook 是 Xposed 框架中用来 Hook 方法的核心类。它让你可以在方法执行前后添加自己的代码。
XposedHelpers 最常用的方法
// 1. Hook 相关
XposedHelpers.findAndHookMethod( // Hook 实例方法className, // 类名classLoader, // 类加载器methodName, // 方法名parameterTypes, // 参数类型callback // 回调
);XposedHelpers.findAndHookConstructor( // Hook 构造方法className,classLoader,parameterTypes,callback
);// 2. 查找类
Class<?> cls = XposedHelpers.findClass("com.example.TargetClass",classLoader
);// 3. 调用方法
// 调用实例方法
Object result = XposedHelpers.callMethod(object, // 对象"methodName", // 方法名params // 参数
);// 调用静态方法
Object result = XposedHelpers.callStaticMethod(className, // 类名"methodName", // 方法名params // 参数
);// 4. 获取/设置字段
// 获取实例字段
Object value = XposedHelpers.getObjectField(object, // 对象"fieldName" // 字段名
);// 获取静态字段
Object value = XposedHelpers.getStaticObjectField(className, // 类名"fieldName" // 字段名
);// 设置实例字段
XposedHelpers.setObjectField(object, // 对象"fieldName", // 字段名newValue // 新值
);// 设置静态字段
XposedHelpers.setStaticObjectField(className, // 类名"fieldName", // 字段名newValue // 新值
);// 5. 创建新实例
Object newObj = XposedHelpers.newInstance(className, // 类名params // 构造参数
);
setAccessible 是 Java 反射中用来绕过访问限制的方法。它可以让你访问私有成员:
// 1. 基本用法
Field field = targetClass.getDeclaredField("privateField");
field.setAccessible(true); // 允许访问私有字段
Object value = field.get(object); // 现在可以访问了// 2. 实际例子
try {// 获取私有方法Method method = targetClass.getDeclaredMethod("privateMethod");method.setAccessible(true); // 设置可访问method.invoke(object); // 调用私有方法// 获取私有字段Field field = targetClass.getDeclaredField("privateField");field.setAccessible(true); // 设置可访问field.set(object, newValue); // 修改私有字段值
} catch (Exception e) {XposedBridge.log("访问失败: " + e);
}
- Java.use().$new(): 创建新的 Java 对象
- Java.cast(): 转换对象类型
- Java.choose(): 查找已存在的对象实例
$new() 里面的参数对应 Java 类的构造函数参数。让我用具体例子说明:
Java.perform(function() {// 1. 无参数构造函数var StringBuilder = Java.use("java.lang.StringBuilder");var sb1 = StringBuilder.$new(); // 等同于 new StringBuilder()// 2. 带参数构造函数var sb2 = StringBuilder.$new("Hello"); // 等同于 new StringBuilder("Hello")// 3. String 类例子var String = Java.use("java.lang.String");var str1 = String.$new(); // new String()var str2 = String.$new("Hello"); // new String("Hello")// 4. 自定义类例子var MyClass = Java.use("com.example.MyClass");// 如果 MyClass 构造函数需要两个参数:String 和 intvar myObj = MyClass.$new("参数1", 123); // new MyClass("参数1", 123)
});
下面是Java.cast(): 转换对象类型:
- View 就像是一个"容器"
- 通过 cast 告诉系统:"这个容器其实是个按钮"
- 转换后就可以用按钮特有的功能了
Java.perform(function() {// 1. 找到一个普通的 ViewJava.choose("com.example.MainActivity", {onMatch: function(activity) {// findViewById 返回的是 View 类型var view = activity.findViewById(123); // 这时候只能用 View 的方法// 把 View 转成 Buttonvar button = Java.cast(view, Java.use("android.widget.Button"));// 现在可以用 Button 特有的方法了button.setText("点击我"); // 设置按钮文字button.setEnabled(true); // 设置按钮可点击button.setOnClickListener(/* ... */); // 设置点击事件}});
});
// TextView 转换
var textView = Java.cast(view, Java.use("android.widget.TextView"));
textView.setText("这是文本");// ImageView 转换
var imageView = Java.cast(view, Java.use("android.widget.ImageView"));
imageView.setImageResource(R.drawable.icon);// EditText 转换
var editText = Java.cast(view, Java.use("android.widget.EditText"));
editText.setHint("请输入...");
主要区别:
- $new() 是 Frida (JavaScript) 的方法
- newInstance 是 Xposed (Java) 的方法
- 功能是一样的,都是创建新对象
简单说:
- 在 Frida 脚本中用 $new()
- 在 Xposed 模块中用 newInstance
Frida 的 $new()
Java.perform(function() {// Frida 方式创建对象var String = Java.use("java.lang.String");var str = String.$new("Hello"); // 创建字符串var ArrayList = Java.use("java.util.ArrayList");var list = ArrayList.$new(); // 创建列表
});
Xposed 的 newInstance
// Xposed 方式创建对象
String str = (String) XposedHelpers.newInstance(String.class, // 类"Hello" // 参数
);ArrayList list = (ArrayList) XposedHelpers.newInstance(ArrayList.class // 类
);
简单说就是三步:
- 找到类 (findClass)
- 创建实例 (newInstance)
- 调用方法 (callMethod)
public void handleLoadPackage(LoadPackageParam lpparam) throws Throwable {// 1. 首先查找类Class<?> targetClass = XposedHelpers.findClass("com.example.TargetClass", // 类名lpparam.classLoader // 类加载器);// 2. 创建类的实例Object instance = XposedHelpers.newInstance(targetClass);// 如果构造函数有参数// Object instance = XposedHelpers.newInstance(targetClass, "参数1", 123);// 3. 调用实例的方法XposedHelpers.callMethod(instance, // 实例对象"methodName", // 方法名"参数1", // 方法参数123 // 更多参数);
}
NanoHTTPD
NanoHTTPD 是一个轻量级的 HTTP 服务器库。
相关文章:
Xposed-Hook
配置 Xposed 模块的 AndroidManifest.xml: <?xml version"1.0" encoding"utf-8"?> <manifest xmlns:android"http://schemas.android.com/apk/res/android"package"your.package.name"><applicationandr…...
【PyTorch】6.张量形状操作:在深度学习的 “魔方” 里,玩转张量形状
目录 1. reshape 函数的用法 2. transpose 和 permute 函数的使用 4. squeeze 和 unsqueeze 函数的用法 5. 小节 个人主页:Icomi 专栏地址:PyTorch入门 在深度学习蓬勃发展的当下,PyTorch 是不可或缺的工具。它作为强大的深度学习框架&am…...
实现基础的shell程序
1. 实现一个基础的 shell 程序,主要完成两个命令的功能 cp 和 ls 1.1.1. cp 命令主要实现: ⽂件复制⽬录复制 1.1.2. ls 命令主要实现: ls -l 命令的功能 1.1. 在框架设计上,采⽤模块化设计思想,并具备⼀定的可扩…...
【Numpy核心编程攻略:Python数据处理、分析详解与科学计算】1.18 逻辑运算引擎:数组条件判断的智能法则
1.18 逻辑运算引擎:数组条件判断的智能法则 1.18.1 目录 #mermaid-svg-QAFjJvNdJ5P4IVbV {font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}#mermaid-svg-QAFjJvNdJ5P4IVbV .error-icon{fill:#552222;}#mermaid-svg-QAF…...
【Leetcode 每日一题】350. 两个数组的交集 II
问题背景 给你两个整数数组 n u m s 1 nums_1 nums1 和 n u m s 2 nums_2 nums2,请你以数组形式返回两数组的交集。返回结果中每个元素出现的次数,应与元素在两个数组中都出现的次数一致(如果出现次数不一致,则考虑取较小值…...
一文读懂fgc之cms
一文读懂 fgc之cms-实战篇 1. 前言 线上应用运行过程中可能会出现内存使用率较高,甚至达到95仍然不触发fgc的情况,存在内存打满风险,持续触发fgc回收;或者内存占用率较低时触发了fgc,导致某些接口tp99,tp…...
集合的奇妙世界:Python集合的经典、避坑与实战
集合的奇妙世界:Python集合的经典、避坑与实战 内容简介 本系列文章是为 Python3 学习者精心设计的一套全面、实用的学习指南,旨在帮助读者从基础入门到项目实战,全面提升编程能力。文章结构由 5 个版块组成,内容层层递进&#x…...
知识库管理系统助力企业实现知识共享与创新价值的转型之道
内容概要 知识库管理系统(KMS)作为现代企业知识管理的重要组成部分,其定义涵盖了系统化捕捉、存储、共享和应用知识的过程。这类系统通过集成各种信息来源,不仅为员工提供了一个集中式的知识平台,还以其结构化的方式提…...
SpringBoot 日志与配置文件
SpringBoot 配置文件格式 Properties 格式 Component ConfigurationProperties(prefix "person") //和配置文件person前缀的所有配置进行绑定 Data public class Person {private String name;private Integer age;private Date birthDay;private Boolean like;pr…...
【C语言】static关键字的三种用法
【C语言】static关键字的三种用法 C语言中的static关键字是一个存储类说明符,它可以用来修饰变量和函数。static关键字的主要作用是控制变量或函数的生命周期和可见性。以下是static关键字的一些主要用法和含义: 局部静态变量: 当static修饰…...
Qt中Widget及其子类的相对位置移动
Qt中Widget及其子类的相对位置移动 最后更新日期:2025.01.25 下面让我们开始今天的主题… 一、开启篇 提出问题:请看上图,我们想要实现的效果是控件黄色的Widge(m_infobarWidget)t随着可视化窗口(m_glWidge…...
【Node.js】Koa2 整合接口文档
部分学习来源:https://blog.csdn.net/qq_38734862/article/details/107715579 依赖 // koa2-swagger-ui UI视图组件 swagger-jsdoc 识别写的 /***/ 转 json npm install koa2-swagger-ui swagger-jsdoc --save配置 config\swaggerConfig.js const Router requir…...
Docker/K8S
文章目录 项目地址一、Docker1.1 创建一个Node服务image1.2 volume1.3 网络1.4 docker compose 二、K8S2.1 集群组成2.2 Pod1. 如何使用Pod(1) 运行一个pod(2) 运行多个pod 2.3 pod的生命周期2.4 pod中的容器1. 容器的生命周期2. 生命周期的回调3. 容器重启策略4. 自定义容器启…...
leetcode——排序链表(java)
给你链表的头结点 head ,请将其按 升序 排列并返回 排序后的链表 。 示例 1: 输入:head [4,2,1,3] 输出:[1,2,3,4] 示例 2: 输入:head [-1,5,3,4,0] 输出:[-1,0,3,4,5] 示例 3: …...
告别重启!Vue CLI 动态代理配置实战:实现热更新与灵活配置
在前端开发中,代理配置是解决跨域问题的常见手段。尤其是在使用 Vue CLI 进行开发时,我们经常需要通过 devServer.proxy 来配置代理。然而,传统的代理配置通常是静态的,修改后需要重启开发服务器,这在频繁调整代理配置…...
基于springboot的校园部门资料管理系统
博主介绍:java高级开发,从事互联网行业多年,熟悉各种主流语言,精通java、python、php、爬虫、web开发,已经做了多年的设计程序开发,开发过上千套设计程序,没有什么华丽的语言,只有实…...
数据结构初阶之堆的介绍与堆的实现
一、堆的概念与结构 如果有一个关键码的集合,把它的所有元素按完全二叉树的顺序存储在一个一维数组中,并满足:,则称为小堆(或大堆)。 将根结点最大的堆叫做最大堆或大根堆,根结点最小的堆叫做…...
Day29(补)-【AI思考】-精准突围策略——从“时间贫困“到“效率自由“的逆袭方案
文章目录 精准突围策略——从"时间贫困"到"效率自由"的逆袭方案**第一步:目标熵减工程(建立四维坐标)** 与其他学习方法的结合**第二步:清华方法本土化移植** 与其他工具对比**~~第三步:游戏化改造…...
docker中运行的MySQL怎么修改密码
1,进入MySQL容器 docker exec -it 容器名 bash 我运行了 docker ps命令查看。正在运行的容器名称。可以看到MySQL的我起名为db docker exec -it db bash 这样就成功的进入到容器中了。 2,登录MySQL中 mysql -u 用户名 -p 回车 密码 mysql -u root -p roo…...
leetcode——二叉树的中序遍历(java)
给定一个二叉树的根节点 root ,返回 它的 中序 遍历 。 示例 1: 输入:root [1,null,2,3] 输出:[1,3,2] 示例 2: 输入:root [] 输出:[] 示例 3: 输入:root [1] 输出…...
腾讯云开发提供免费GPU服务
https://ide.cloud.tencent.com/dashboard/web 适用于推理场景,每个月10000分钟免费时长 166 小时 40 分钟 自带学术加速,速度还是不错的 白嫖 Tesla T4 16G 算力 显存:16GB 算力:8 TFlops SP CPU:8 核 内存&#…...
信息安全专业优秀毕业设计选题汇总:热点选题
目录 前言 毕设选题 开题指导建议 更多精选选题 选题帮助 最后 前言 大家好,这里是海浪学长毕设专题! 大四是整个大学期间最忙碌的时光,一边要忙着准备考研、考公、考教资或者实习为毕业后面临的升学就业做准备,一边要为毕业设计耗费大量精力。学长给大家整理…...
Java---猜数字游戏
本篇文章所实现的是Java经典的猜数字游戏 , 运用简单代码来实现基本功能 目录 一.题目要求 二.游戏准备 三.代码实现 一.题目要求 随机生成一个1-100之间的整数(可以自己设置区间),提示用户猜测,猜大提示"猜大了",…...
SAP系统中的主要采购类型/采购模式总结
在 SAP 系统中,采购类型主要有以下几种: 一、标准采购订单(Standard Purchase Order) 描述:这是最常用的采购类型,用于一次性采购货物或服务。采购部门根据需求部门提出的采购申请,向供应商发出采购订单,明确规定了采购的物料、数量、价格、交货日期等详细信息。 应…...
从0开始使用面对对象C语言搭建一个基于OLED的图形显示框架(基础组件实现)
目录 基础组件实现 如何将图像和文字显示到OLED上 如何绘制图像 如何绘制文字 如何获取字体? 如何正确的访问字体 如何抽象字体 如何绘制字符串 绘制方案 文本绘制 更加方便的绘制 字体附录 ascii 6x8字体 ascii 8 x 16字体 基础组件实现 我们现在离手…...
《深入浅出HTTPS》读书笔记(31):HTTPS和TLS/SSL
《深入浅出HTTPS》读书笔记(31):HTTPS和TLS/SSL TLS/SSL协议和应用层协议无关,它只是加密应用层协议(比如HTTP)并传递给下层的TCP。 HTTP和TLS/SSL协议组合在一起就是HTTPS, HTTPS等…...
论文笔记(六十三)Understanding Diffusion Models: A Unified Perspective(五)
Understanding Diffusion Models: A Unified Perspective(五) 文章概括基于得分的生成模型(Score-based Generative Models) 文章概括 引用: article{luo2022understanding,title{Understanding diffusion models: A…...
量子编程语言:Qiskit 与 Cirq
在量子计算的领域,开发者已经可以使用一些专门为量子计算设计的编程语言和框架。其中,Qiskit 和 Cirq 是两个非常流行的选择,它们为不同的量子计算机提供编程接口,帮助开发者理解量子电路的设计和执行。 1. Qiskit Qiskit 是 IBM…...
floodfill算法(6题)
本质就是找出性质相似的连通块 目录 1.图像渲染 2.岛屿数量 3.岛屿的最大面积 4.被围绕的区域 5.太平洋大西洋水流问题 6.扫雷游戏 1.图像渲染 733. 图像渲染 - 力扣(LeetCode) 我们使用深度优先遍历去遍历即可,也不需要返回值。 值得…...
ThinkPHP 8模型与数据的插入、更新、删除
【图书介绍】《ThinkPHP 8高效构建Web应用》-CSDN博客 《2025新书 ThinkPHP 8高效构建Web应用 编程与应用开发丛书 夏磊 清华大学出版社教材书籍 9787302678236 ThinkPHP 8高效构建Web应用》【摘要 书评 试读】- 京东图书 使用VS Code开发ThinkPHP项目-CSDN博客 编程与应用开…...
