转载:TableView性能优化

转载:TableView性能优化

原文链接:https://juejin.cn/post/6955731915672387592

tableView性能优化

Cell重用、标识重用

使用 static 修饰重用标识名称能够保证这个标识只会创建一次,提高性能。接着调用dequeueReusableCellWithIdentifier:方法 获取缓存池中的Cell。如果没有就调用 initWithStyle:ReusIdentifier:方法 创建一个新的Cell。注意事先需要调用registerNib/registerClass方法TableView 注册一下重用标识。

动态高度

我们需要实现它的代理,来给出高度:

objectivec复制代码- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {// return xxx
}

这个代理方法实现后,上面的 rowHeight 的设置将会变成无效。在这个方法中,我们需要提高cell高度的计算效率,来节省时间。

自从iOS8之后有了 self-sizing cell的概念,cell可以自己算出高度,使用self-sizing cell需要满足以下三个条件:

(1)使用 Autolayout 进行 UI布局约束(要求cell.contentView的四条边都与内部元素有约束关系)。

(2)指定 TableViewestimatedRowHeight属性 的默认值。

(3)指定 TableView的rowHeight 属性为 UITableViewAutomaticDimension

ini复制代码- (void)viewDidload {self.myTableView.estimatedRowHeight = 44.0;self.myTableView.rowHeight = UITableViewAutomaticDimension;
}

除了提高cell高度的计算效率之外,对于已经计算出的高度,我们需要进行缓存,对于已经计算过的高度,没有必要进行计算第二次。

减少视图的数目

我们在 cell 上添加系统控件的时候,实际上系统都会调用底层的接口进行绘制,大量添加控件时,会消耗很大的资源并且也会影响渲染的性能。当使用默认的 UITableViewCell 并且在它的 ContentView 上面添加控件时会相当消耗性能。所以目前最佳的方法还是继承 UITableViewCell,并重写drawRect方法

重绘操作仍然在 drawRect方法 中完成,但是苹果不建议直接调用 drawRect方法,当然如果你强直直接调用此方法,当然是没有效果的。苹果要求我们调用UIView类中的 setNeedsDisplay方法,则程序会自动调用 drawRect方法 进行重绘。(调用 setNeedsDisplay 会自动调用 drawRect)。

使用hidden隐藏图层

避免动态添加图层。在初始化cell的时候一并将所有图层预先创建好,通过hidden属性控制子图层的显示或隐藏,因为单纯的显示操作要比创建快的多。

在快速滚动时考虑使用界面外壳

使用有外壳的界面.png

当用户快速滚动列表视图时,虽然使用了所有的优化,但视图的重用和渲染仍然需要超过 16 毫秒,还有可能出现偶发的丢帧现象,从而导致不流畅的体验。

在这些情况下,使用一个界面外壳是一个较好的选择,外壳可以被预先定义,它的唯一目的就是告诉终端用户这些部分即将展示一些数据。当滚动速度降低,并低于阈值时,刷新最终的视图并填充数据。

你可以使用与列表视图相关联的 panGestureRecognizer 属性获取速度值。

// 列表视图的速度
-(void)scrollViewDidScroll:(UIScrollView *)scrollView { CGPoint velocity = [tableView.panGestureRecognizer velocityInView:self.view]; self.velocity = velocity;
}
-(UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
if(fabs(self.velocity.y) > 2000) { //返回界面外壳} else {//返回真正的单元格 }
}

避免离屛渲染。

开启离屛渲染的代价就是需要新开辟一块新的缓冲区,在渲染的过程中还会多次的切换上下文,这些都是很消耗性能的。以下情况均会造成离屛渲染:

1. shadows(阴影)

其原因在于需要显示在所有layer内容的下方,因此必须被渲染在先。但此时阴影的本体(layer和其子layer)都还没有被组合到一起,只能另外申请一块内存,把本体内容都先画好,再根据渲染结果的形状,添加阴影到帧缓冲区frame buffer,最后把内容画上去。不过如果我们能够预先告诉CoreAnmation(通过 shadowPath属性 )阴影的几何形状,那么阴影当然可以先被独立渲染出来,不需要依赖layer本体,也就不再需要离屏渲染了。

2. 设置了组透明度为YES,并且透明度不为1的layer(不透明)

产生离屏渲染的条件是layer.opacity != 1.0并且有 子layer 或者背景图。alpha并不是分别应用在每一层之上,而是只有到 整个layer树 画完之后,再统一加上alpha,最后和底下其他layer的像素进行组合。显然也无法通过一次遍历就得到最终结果。

3. masks(遮罩)

我们知道mask是应用在layer和其所有子layer的组合之上的,而且可能带有透明度,那么其实和group opacity的原理类似,不得不在离屏渲染中完成。

4. cornerRadius+clipsToBounds

容器的子layer因为父容器有圆角,那么也会需要被裁剪,而这时它们还在渲染队列中排队,尚未被组合到一块画布上,自然也无法统一裁剪,不得已只能另开一块内存来操作。而如果只是设置cornerRadius,并不会触发离屏渲染。

5. shouldRasterize(光栅化)

如果layer不是静态的,我们更新已光栅化的layer,会造成大量的离屏渲染。 例如UITableViewCell因为复用的原因,重绘是很频繁的。如果此时设置了光栅化,会造成大量离屏渲染,降低性能。 不要过度使用,系统限制了缓存的大小为 2.5 * Screen Size。超出缓存之后,同样会造成大量的离屏渲染。 离屏渲染缓存内容有时间限制,被光栅化的图片(即缓存内容)如果超过100ms没有被使用,那么它就会丢弃,无法进行复用。(所以光栅化只能用在图像内容不变的前提下,且只对连续不断使用的图片进行缓存:用于避免静态内容的复杂特效的重绘,例如UIBlurEffect用于避免多个View嵌套的复杂View的重绘。)

6. edge antialiasing(抗锯齿)

分页加载数据,预先异步请求数据 - Prefetching API

viewDidLoad 中先请求网络数据来获取一些初始化数据,然后再利用 UITableViewPrefetching API 来对数据进行预加载,从而来实现数据的无缝加载。

UITableViewDataSourcePrefetching 协议
// this protocol can provide information about cells before they are displayed on screen.@protocol UITableViewDataSourcePrefetching <NSObject>@required// indexPaths are ordered ascending by geometric distance from the table view
- (void)tableView:(UITableView *)tableView prefetchRowsAtIndexPaths:(NSArray<NSIndexPath *> *)indexPaths;@optional// indexPaths that previously were considered as candidates for pre-fetching, but were not actually used; may be a subset of the previous call to -tableView:prefetchRowsAtIndexPaths:
- (void)tableView:(UITableView *)tableView cancelPrefetchingForRowsAtIndexPaths:(NSArray<NSIndexPath *> *)indexPaths;@end

第一个函数会基于当前滚动的方向和速度对接下来的 IndexPaths 进行 Prefetch,通常我们会在这里实现预加载数据的逻辑。

第二个函数是一个可选的方法,当用户快速滚动导致一些 Cell 不可见的时候,你可以通过这个方法来取消任何挂起的数据加载操作,有利于提高滚动性能, 在下面我会讲到。

实现这俩个函数的逻辑代码为:

swift复制代码extension ViewController: UITableViewDataSourcePrefetching {// 翻页请求func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {let needFetch = indexPaths.contains { $0.row >= viewModel.currentCount}if needFetch {// 1.满足条件进行翻页请求indicatorView.startAnimating()viewModel.fetchImages()}for indexPath in indexPaths {if let _ = viewModel.loadingOperations[indexPath] {return}if let dataloader = viewModel.loadImage(at: indexPath.row) {print("在 \(indexPath.row) 行 对图片进行 prefetch ")// 2 对需要下载的图片进行预热viewModel.loadingQueue.addOperation(dataloader)// 3 将该下载线程加入到记录数组中以便根据索引查找viewModel.loadingOperations[indexPath] = dataloader}}}func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]){// 该行在不需要显示的时候,取消 prefetch ,避免造成资源浪费indexPaths.forEach {if let dataLoader = viewModel.loadingOperations[$0] {print("在 \($0.row) 行 cancelPrefetchingForRowsAt ")dataLoader.cancel()viewModel.loadingOperations.removeValue(forKey: $0)}}}
}

最后,再加上俩个有用的方法该功能就大功告成了:

   // 用于计算 tableview 加载新数据时需要 reload 的 cellfunc visibleIndexPathsToReload(intersecting indexPaths: [IndexPath]) -> [IndexPath] {let indexPathsForVisibleRows = tableView.indexPathsForVisibleRows ?? []let indexPathsIntersection = Set(indexPathsForVisibleRows).intersection(indexPaths)return Array(indexPathsIntersection)}// 用于确定该索引的行是否超出了目前收到数据的最大数量func isLoadingCell(for indexPath: IndexPath) -> Bool {return indexPath.row >= (viewModel.currentCount)}

滑动TableView时,按需加载内容

有些情况下我们可能会去快速的滑动列表,这时候其实会有大量的cell对象被创建、被重用,但其实我们可能只是去浏览列表停止的那一页的上下一定范围内的信息,前面快速划过的那些信息对我们来说都是无用的。此时我们可以通过ScrollView的代理方法

scrollViewWillEndDragging: withVelocity: targetContentoffset:

来按需加载内容。

#pragma mark - UIScrollViewDelegate  
//按需加载 - 如果目标行与当前行相差超过指定行数,只在目标滚动范围的前后指定3行加载。  
- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset {  NSIndexPath *targetPath = [_myTableView indexPathForRowAtPoint:CGPointMake(0, targetContentOffset->y)];  NSIndexPath *firstVisiblePath = [[_myTableView indexPathsForVisibleRows] firstObject];  NSInteger skipCount = 8;  if (labs(firstVisiblePath.row - targetPath.row)>  skipCount) {  NSArray *temp = [_myTableView indexPathsForRowsInRect:CGRectMake(0, targetContentOffset->y, _myTableView.frame.size.width, _myTableView.frame.size.height)];  NSMutableArray *arr = [NSMutableArray arrayWithArray:temp];  if (velocity.y<0) {  NSIndexPath *indexPath = [temp lastObject];  if (indexPath.row+33) {  [arr addObject:[NSIndexPath indexPathForRow:indexPath.row-3 inSection:0]];  [arr addObject:[NSIndexPath indexPathForRow:indexPath.row-2 inSection:0]];  [arr addObject:[NSIndexPath indexPathForRow:indexPath.row-1 inSection:0]];  }  }  [_dataList addObjectsFromArray:arr];  }  
}  

targetContentOffsetTableView 减速到停止的地方, velocity 表示速度向量。

如何避免滚动时的卡顿: 异步化UI,不要阻塞主线程

当你遇到滚动卡顿的应用程序时,通常是由于任务长时间运行阻碍了 UI 在主线程上的更新,想让主线程有空来响应这类更新事件,第一步就是要将消耗时间的任务交给子线程去执行,避免在获取数据时阻塞主线程。

苹果提供了很多为应用程序实现并发的方式,例如 GCD,我在这里对 Cell 上的图片进行异步加载使用的就是它。 代码如下:

swift复制代码class DataLoadOperation: Operation {var image: UIImage?var loadingCompleteHandle: ((UIImage?) -> ())?private var _image: ImageModelprivate let cachedImages = NSCache<NSURL, UIImage>()init(_ image: ImageModel) {_image = image}public final func image(url: NSURL) -> UIImage? {return cachedImages.object(forKey: url)}override func main() {if isCancelled {return}guard let url = _image.url else {return}downloadImageFrom(url) { (image) inDispatchQueue.main.async { [weak self] inguard let ss = self else { return }if ss.isCancelled { return }ss.image = imagess.loadingCompleteHandle?(ss.image)}}}// Returns the cached image if available, otherwise asynchronously loads and caches it.func downloadImageFrom(_ url: NSURL, completeHandler: @escaping (UIImage?) -> ()) {// Check for a cached image.if let cachedImage = image(url: url) {DispatchQueue.main.async {print("命中缓存")completeHandler(cachedImage)}return}URLSession.shared.dataTask(with: url as URL) { data, response, error inguardlet httpURLResponse = response as? HTTPURLResponse, httpURLResponse.statusCode == 200,let mimeType = response?.mimeType, mimeType.hasPrefix("image"),let data = data, error == nil,let _image = UIImage(data: data)else { return }// Cache the image.self.cachedImages.setObject(_image, forKey: url, cost: data.count)completeHandler(_image)}.resume()}
}

在willDisplayCell:forRowAtIndexPath:代理方法中绑定数据

那具体如何使用呢!别急,听我娓娓道来,这里我再给大家一个小建议,大家都知道 UITableView 实例化 Cell 的方法是:tableView:cellForRowAtIndexPath: ,相信很多人都会在这个方法里面去进行数据绑定然后更新 UI,其实这样做是一种比较低效的行为,因为这个方法需要为每个 Cell 调用一次,它应该快速的执行并返回重用 Cell 的实例,不要在这里去执行数据绑定,因为目前在屏幕上还没有 Cell。我们可以在 tableView:willDisplayCell:forRowAtIndexPath: 这个方法中进行数据绑定,这个方法在显示cell之前会被调用。

为每个 Cell 执行下载任务的实现代码如下:

swift复制代码 func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {guard let cell = tableView.dequeueReusableCell(withIdentifier: "PreloadCellID") as? ProloadTableViewCell else {fatalError("Sorry, could not load cell")}if isLoadingCell(for: indexPath) {cell.updateUI(.none, orderNo: "\(indexPath.row)")}return cell}func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {// preheat image ,处理将要显示的图像guard let cell = cell as? ProloadTableViewCell else {return}// 图片下载完毕后更新 celllet updateCellClosure: (UIImage?) -> () = { [unowned self] (image) incell.updateUI(image, orderNo: "\(indexPath.row)")viewModel.loadingOperations.removeValue(forKey: indexPath)}// 1. 首先判断是否已经存在创建好的下载线程if let dataLoader = viewModel.loadingOperations[indexPath] {if let image = dataLoader.image {// 1.1 若图片已经下载好,直接更新cell.updateUI(image, orderNo: "\(indexPath.row)")} else {// 1.2 若图片还未下载好,则等待图片下载完后更新 celldataLoader.loadingCompleteHandle = updateCellClosure}} else {// 2. 没找到,则为指定的 url 创建一个新的下载线程print("在 \(indexPath.row) 行创建一个新的图片下载线程")if let dataloader = viewModel.loadImage(at: indexPath.row) {// 2.1 添加图片下载完毕后的回调dataloader.loadingCompleteHandle = updateCellClosure// 2.2 启动下载viewModel.loadingQueue.addOperation(dataloader)// 2.3 将该下载线程加入到记录数组中以便根据索引查找viewModel.loadingOperations[indexPath] = dataloader}}}

对预加载的图片进行异步下载(预热):

swift复制代码func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {let needFetch = indexPaths.contains { $0.row >= viewModel.currentCount}if needFetch {// 1.满足条件进行翻页请求indicatorView.startAnimating()viewModel.fetchImages()}for indexPath in indexPaths {if let _ = viewModel.loadingOperations[indexPath] {return}if let dataloader = viewModel.loadImage(at: indexPath.row) {print("在 \(indexPath.row) 行 对图片进行 prefetch ")// 2 对需要下载的图片进行预热viewModel.loadingQueue.addOperation(dataloader)// 3 将该下载线程加入到记录数组中以便根据索引查找viewModel.loadingOperations[indexPath] = dataloader}}}

取消 Prefetch 时,cancel 任务,避免造成资源浪费

swift复制代码func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]){// 该行在不需要显示的时候,取消 prefetch ,避免造成资源浪费indexPaths.forEach {if let dataLoader = viewModel.loadingOperations[$0] {print("在 \($0.row) 行 cancelPrefetchingForRowsAt ")dataLoader.cancel()viewModel.loadingOperations.removeValue(forKey: $0)}}}

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

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

相关文章

超越架构师!消息通知系统优化设计

5 收集联系信息流程 为发送通知&#xff0c;需收集各种信息如移动设备令牌、email、phone和第三方通道信息。 用于存储联系信息的简化的数据库表模式。它是个带有电子邮件、电话、设备令牌和外部通道的单个NoSQL DynamoDB表。Contacts table schema&#xff1a; device_tokens…

LeetCode(63)旋转链表【链表】【中等】

目录 1.题目2.答案3.提交结果截图 链接&#xff1a; 旋转链表 1.题目 给你一个链表的头节点 head &#xff0c;旋转链表&#xff0c;将链表每个节点向右移动 k 个位置。 示例 1&#xff1a; 输入&#xff1a;head [1,2,3,4,5], k 2 输出&#xff1a;[4,5,1,2,3]示例 2&…

C语言--求数组的最大值和最小值【两种方法】

&#x1f357;方法一&#xff1a;用for循环遍历数组&#xff0c;找出最大值与最小值 &#x1f357;方法二&#xff1a;用qsort排序&#xff0c;让数组成为升序的有序数组&#xff0c;第一个值就是最小值&#xff0c;最后一个是最大值 完整代码&#xff1a; 方法一&#xff1a; …

基于Nexus搭建Maven私服基础入门

什么是Nexus&#xff1f;它有什么优势&#xff1f; 要了解为什么需要nexus的存在&#xff0c;我们不妨从以下几个问题来简单了解一下: 为什么需要搭建私服&#xff1f;如果没有私服会出现什么问题&#xff1f; 对于企业开发而言&#xff0c;如果没有私服&#xff0c;我们所有…

自定义日志打印功能--C++

一、介绍 日志是计算机程序中用于记录运行时事件和状态的重要工具。通过记录关键信息和错误情况&#xff0c;日志可以帮助程序开发人员和维护人员追踪程序的执行过程&#xff0c;排查问题和改进性能。 在软件开发中&#xff0c;日志通常记录如下类型的信息&#xff1a; 事件信…

【Flink系列七】TableAPI和FlinkSQL初体验

Apache Flink 有两种关系型 API 来做流批统一处理&#xff1a;Table API 和 SQL Table API 是用于 Scala 和 Java 语言的查询API&#xff0c;它可以用一种非常直观的方式来组合使用选取、过滤、join 等关系型算子。 Flink SQL 是基于 Apache Calcite 来实现的标准 SQL。无论输…

文章解读与仿真程序复现思路——电网技术EI\CSCD\北大核心《基于时空注意力卷积模型的超短期风电功率预测》

这个标题描述了一种用于超短期风电功率预测的模型&#xff0c;该模型基于时空注意力卷积模型。下面我会逐步解读这个标题的关键词和背景&#xff1a; 超短期风电功率预测&#xff1a;风电功率预测是指根据历史风速和其他相关数据&#xff0c;通过建立数学模型来预测未来特定时间…

Windows使用selenium操作浏览器爬虫

以前的大部分程序都是操作Chrome&#xff0c;很少有操作Edge&#xff0c;现在以Edge为例。 Selenium本身是无法直接控制浏览器的&#xff0c;不同的浏览器需要不同的驱动程序&#xff0c;Google Chrome需要安装ChromeDriver、Edge需要安装Microsoft Edge WebDriver&#xff0c…

DevOps搭建(九)-Jenkins实现基础CI、CD详细操作

1、创建可运行SpringBoot项目 1.1、创建一个新工程 在idea里创建一个项目,这里叫devops-test,如下图: String Boot版本要选择2.x的,依赖直选中Spring Web选项即可: 修改pom.xml文件,在build标签中增加如下内容,目的是简化jar包名称。 <finalName>devops-test&l…

Ubuntu虚拟机怎么设置静态IP

1 首先先ifconfig看一下使用的是哪个网络接口&#xff1a; 2 编辑 sudo vi /etc/netplan/00-installer-config.yamlnetwork:ethernets:ens33: # 根据您的网络接口进行修改&#xff0c;有的是eth0&#xff0c;有的是ens33&#xff0c;具体看第一步显示的是哪个网络接口addres…

Knife4j 接口文档如何设置 Authorization 鉴权参数?

&#x1f680; 作者主页&#xff1a; 有来技术 &#x1f525; 开源项目&#xff1a; youlai-mall &#x1f343; vue3-element-admin &#x1f343; youlai-boot &#x1f33a; 仓库主页&#xff1a; Gitee &#x1f4ab; Github &#x1f4ab; GitCode &#x1f496; 欢迎点赞…

针对基于nohup后台运行PyTorch多卡并行程序中断问题的一种新方法

针对基于nohup后台运行PyTorch多卡并行程序中断问题的一种新方法 文章目录 针对基于nohup后台运行PyTorch多卡并行程序中断问题的一种新方法Abstractscreen和tmux介绍tmux常用命令以及快捷键Byobu简单操作步骤集锦参考文献 Abstract PyTorch多卡并行运行程序is one of the mos…

网络互通--三层交换机配置

目录 一、三层交换机的原理 1、概念 2、PC A与不同网段的PC B第一次数据转发过程 3、一次路由&#xff0c;多次转发的概念 4、 三层交换机和路由器的比较 二、利用实验理解交换机 1、建立以下拓扑图​编辑 2、分别配置主机的IP地址&#xff0c;子网掩码、网关等信息 3、…

小白学爬虫:根据商品ID或商品链接获取淘宝商品详情数据接口方法

小白学爬虫的准备工作包括以下几个方面&#xff1a; 学习Python基础知识&#xff1a;首先需要掌握Python编程语言的基本语法和数据类型&#xff0c;了解Python的常用库和模块&#xff0c;例如requests库等。了解HTTP协议和HTML语言&#xff1a;了解HTTP协议的基本概念和原理&a…

【Hadoop_05】NN、2NN以及DataNode的工作机制

1、NameNode和SecondaryNameNode1.1 NN和2NN工作机制1.2 Fsimage和Edits解析1.3 CheckPoint时间设置 2、DataNode2.1 DataNode工作机制2.2 数据完整性2.3 掉线时限参数设置 1、NameNode和SecondaryNameNode 1.1 NN和2NN工作机制 思考&#xff1a;NameNode中的元数据是存储在哪…

ES-组合与聚合

ES组合查询 1 must 满足两个match才会被命中 GET /mergeindex/_search {"query": {"bool": {"must": [{"match": {"name": "liyong"}},{"match_phrase": {"desc": "liyong"}}]}}…

消息队列kafka详解:Kafka架构介绍

一. 工作流程 Kafka中消息是以topic进行分类的&#xff0c;Producer生产消息&#xff0c;Consumer消费消息&#xff0c;都是面向topic的。 Topic是逻辑上的改变&#xff0c;Partition是物理上的概念&#xff0c;每个Partition对应着一个log文件&#xff0c;该log文件中存储的就…

SpringBoot接入企微机器人

1、企业微信创建机器人&#xff08;如何创建不懂的请自行百度&#xff0c;很简单的&#xff09;&#xff0c;成功后能获取到一个Webhook地址&#xff1a;https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key693a91f6-7xxx-4bc4-97a0-0ec2sifa5aaa 2、创建一个SpringBoot项…

Leetcode 37 解数独

题意理解&#xff1a; 填充数独。每个九宫格内&#xff0c;9个数字各出现一个次&#xff0c;每行&#xff0c;每列上&#xff0c;9个数字各出现一次。数独部分空格内已填入了数字&#xff0c;空白格用 . 表示。 这道题要比N皇后问题更难&#xff1a; N皇后只放置N个皇后的位置&…

网络安全Web学习记录———CTF---Web---SQL注入(GET和POST传参)例题

小白初见&#xff0c;若有问题&#xff0c;希望各位大哥多多指正~ 我的第一道web类CTF题——一起来撸猫o(•ェ•)m-CSDN博客 最开始学习CTF里的web方向时&#xff0c;每次做了题遇到类似的老是忘记之前的解法&#xff0c;所以写点东西记录一下。听大哥的话&#xff0c;就从最…