☰
WebForms老项目中的ArrayList:机制解析与渐进式重构指南
2026/10/8 19:09:06 网站建设 项目流程

接手过几个老WebForms项目的朋友,一定对ArrayList不陌生。它就像办公室那个老同事,技术水平说不上先进,但项目里到处都有他用过的痕迹,你还得天天跟他打交道。这种非泛型集合在ASP.NET Web Forms里存在感极强,从ViewState到数据绑定,从老业务逻辑到临时缓存,到处都是它的影子。

这篇文章我结合自己处理WebForms遗留代码的经验,专门聊聊ArrayList——它从哪里来,为什么还在,怎么跟它安全共事,以及怎么接住它给你扔过来的各种坑。如果你正被老项目折磨,或者刚接手一个满是ArrayList的WebForms系统,这篇文章就是写给你看的。

1. WebForms里ArrayList的典型用武之地

1.1 老项目中ArrayList为什么还在广泛存在

要说清楚这个问题,得先理解.NET平台的年代背景。在.NET 1.x时代,泛型还没出生,当时面对"需要存一组对象"的需求,微软给出的标准答案就是ArrayList,它在System.Collections命名空间下,可以动态添加任意类型的元素。等到了.NET 2.0,泛型List<T>才以替代者的姿态出现。

问题在于WebForms项目的生命周期太长,很多系统从2005年左右上线,中途只做功能迭代,不做技术重构。这几年间积累的代码库,要么直接写ArrayList,要么封装在业务层往外抛。哪怕后来引入了List<T>,老代码也懒得专门去改——毕竟在页面后置代码里ArrayList用得好好的,没坏就不修。

所以你现在打开一个老WebForms项目,很可能看到三层结构里业务层返回ArrayList,表示层绑定到Repeater,中间还夹着ViewState存ArrayList的历史包袱。这不是技术选型问题,是历史演进路径的遗留产物。你很难一句话否定它,因为整个系统已经围绕它的行为模式构建起来,贸然替换容易引发连锁问题。

1.2 几个高频使用场景

我在WebForms项目里见到ArrayList用得最多的是三个场景。

数据绑定是第一大场景。老代码里写下拉框数据源,经常是这么干的:

ArrayList categoryList = new ArrayList(); categoryList.Add(new ListItem("全部", "0")); categoryList.Add(new ListItem("数码产品", "1")); // 从数据库读出来的数据循环Add进去 ddlCategory.DataSource = categoryList; ddlCategory.DataTextField = "Text"; ddlCategory.DataValueField = "Value"; ddlCategory.DataBind();

这种写法在当年算标准答案,因为ListItemCollection、DataTable和ArrayList之间互相倒腾很顺手。现在看虽然粗糙,但确实不报错。

函数返回值是第二常见场景。老项目里经常能看到一个方法返回ArrayList,比如:

public ArrayList GetUserListByDeptId(int deptId) { ArrayList users = new ArrayList(); // SQL查询、拼装对象、Add进ArrayList return users; }

调用方拿到这个ArrayList,要么直接循环取数据,要么再塞给某个控件做数据源。因为返回值是弱类型的,你在调用方根本看不出里面装的是什么对象,得追到定义处才知道。这是典型的接口设计缺失,但已经成既定事实了。

ViewState和Session存储是第三个场景。在WebForms时代,把稍微复杂的对象塞进ViewState之前通常会封装成ArrayList,因为它天生支持序列化,随手就能存。可是这里面藏着不少副作用,后面第4章我会专门聊。

2. 拆解ArrayList的核心机制与上手要点

2.1 动态扩容机制

ArrayList底层实际上由一个object[]数组支撑。你Add元素进去时,它内部会先判断当前容量够不够,不够就扩容。每次扩容不是只加一个位置,而是将当前容量翻倍——比如容量从4涨到8,再涨到16,以此类推。

这个机制很像你给家里请客准备椅子:来一个客人加一把太累,索性一次性准备双倍的座位,等座位不够了再统一采购。这种方式的好处是Add操作的平均复杂度接近O(1),坏处是在元素数量反复增减的场景下会浪费内存。如果你往ArrayList里放1万个元素,期间可能有3到4次扩容,最后一次容量可能到16384,比实际需要的多了不少空间。

日常写代码时注意一下Capacity属性。如果事先知道要存多少条数据,直接指定初始容量,能省掉中间多次扩容的开销:

ArrayList list = new ArrayList(10000);

这在做批量数据导入、大量数据预载时非常明显。默认初始容量是0,第一次Add时扩容到4,之后按2倍往上涨。指定初始容量相当于提前买好足够大的场地,后续元素直接往里填,不加收房租。

2.2 常用的API细节与操作陷阱

关于ArrayList的API,有几个地方特别容易跟List<T>搞混,需要单独提一下。

Remove和RemoveAt的区别:Remove是删除传入的对象本身,内部通过Equals判断找到第一个匹配项并删除;RemoveAt是按下标删除。老代码里常有人把list.Remove("abc")写成list.RemoveAt("abc"),编译直接报错。这个还好,起码编译器会提醒你。坑的是反过来:你想删除下标3的元素,却用了Remove(list[3]),如果列表里恰好有两个值相同的元素,删掉的是前一个,不是你想删的那个。这跟物理内存里对象引用地址是有区别的,值类型和字符串特别容易踩。

Insert和Add效果不同。Add是追加到末尾,Insert是插到指定位置。某些老代码在循环里频繁Insert(0, item),等于每次都把后面的元素往后挪,性能非常差。处理大量数据要避免这种头部插入操作,用Add加到最后再反转,或者改用双向链表结构。

Clone是浅拷贝。很多人误以为克隆出来的ArrayList跟原对象完全独立,实际不是。Clone()只复制了列表本身的容器结构和元素引用,里面的引用类型对象还是指向同一块内存。这意味着你改克隆列表里的对象属性,原列表里的对象也跟着变了。

另外ArrayList还有三个不常用的视图方法,分别是FixedSize、ReadOnly和Adapter。前面两个好理解,就是把列表包装成固定大小或只读版本,修改时抛异常。Adapter则是把任意实现了IList接口的对象包装成ArrayList,方便统一操作。说实话这三个方法在实际老项目里出现的频率不高,知道有这回事就行。

2.3 为什么要尽力避免ArrayList的坑

ArrayList最核心的问题有两个,一个是类型安全缺失,一个是装箱拆箱性能损失。

类型安全缺失好理解,因为ArrayList内部是object[],它从根本上不限制你能放什么。同一个列表你可以先Add一个字符串,再Add一个整数,再Add一个自定义实体对象,不会有人拦你。运行过程中这份数据如果被别人反序列化或者强转,类型不匹配时直接抛InvalidCastException。比如你从一个ArrayList里取出第0个元素,强制转成string,实际上它是个int,程序当场爆炸。

装箱拆箱也麻烦。往ArrayList里放值时类型会装箱,取出来再拆箱,这个过程在高频循环下非常伤性能。比如从1万条记录里频繁读取数值型字段,每条数据都走一次装箱拆箱,消耗不可忽略。泛型List<T>之所以能替代ArrayList,核心就是它解决了这两个问题——类型在编译期就固定了,不需要运行时转型,值类型也不会装箱拆箱。

不过说句公道话,如果你只是在一个WebForm页面上关联几十条数据显示到下拉框,ArrayList的性能损失低到可以忽略。真正的性能杀手出现在大数据量和循环拼接上,那种场景一定要优先考虑List<T>或者Dictionary<TKey, TValue>。我会在后面的实战章节里展示具体的性能和取舍对比。

3. 实战:WebForms页面中用ArrayList完成数据绑定全流程

3.1 从数据库查询到填充ArrayList

假设你现在要维护一个老WebForms页面,功能是产品分类管理,页面上有个DropDownList,数据来自数据库。因为历史代码约定业务层返回ArrayList,你也得跟着这个约定走。这里先展示最经典的做法——用SqlDataReader逐行读数据,逐行Add进ArrayList。

public ArrayList GetCategoryList() { ArrayList list = new ArrayList(); string sql = "SELECT CategoryId, CategoryName FROM Category WHERE IsDeleted = 0 ORDER BY SortOrder"; using (SqlConnection conn = new SqlConnection(_connectionString)) { SqlCommand cmd = new SqlCommand(sql, conn); conn.Open(); using (SqlDataReader reader = cmd.ExecuteReader()) { while (reader.Read()) { CategoryInfo item = new CategoryInfo(); item.CategoryId = Convert.ToInt32(reader["CategoryId"]); item.CategoryName = reader["CategoryName"].ToString(); list.Add(item); } } } return list; }

这种写法在现在的标准里谈不上优雅,但它是老项目最常见的形态。这里有个关键细节:reader["CategoryId"]返回的是object,所以用Convert.ToInt32做安全转换,不要直接强转(int)。虽然数据库里字段是int类型,但具体到SQLite、MySQL等不同数据库驱动,返回的底层类型可能有差异,强转容易翻车。

CategoryInfo这个类定义很简单,就是两个属性放值:

[Serializable] public class CategoryInfo { public int CategoryId { get; set; } public string CategoryName { get; set; } }

注意我加了[Serializable]特性。在WebForms年代,凡是可能被丢进ViewState、Session或者做序列化传输的对象,惯例都要加这个标记。后面要用ViewState存ArrayList时,这个特性是硬性要求。

3.2 绑定到Repeater或DropDownList

数据拿回来后,接下来就是绑定。下拉框绑定方式上面已经展示过,比较有代表性的其实是Repeater绑定,因为老项目里商品列表、新闻列表、公告列表全是它的影子。

protected void Page_Load(object sender, EventArgs e) { if (!IsPostBack) { BindCategoryList(); } } private void BindCategoryList() { ArrayList categoryList = GetCategoryList(); rptCategory.DataSource = categoryList; rptCategory.DataBind(); }

Repeater的模板页里对应写<%# ((CategoryInfo)Container.DataItem).CategoryName %>这种形式。这里就暴露了ArrayList的尴尬之处:绑定到DataItem时拿到的还是object,你必须在模板里做类型转换。这个转换如果在数据量大的页面循环里发生,单次不算什么,但一到分页加载、列表刷新之类的高频操作,整体性能就会往下降。

DropDownList绑定相对温和一点,通过DataTextField和DataValueField两个属性指定字段名,内部自行反射获取值,它不要求你强转类型:

ddlCategory.DataSource = categoryList; ddlCategory.DataTextField = "CategoryName"; ddlCategory.DataValueField = "CategoryId"; ddlCategory.DataBind();

这里有个容易忽略的点:绑定DropDownList之后,如果你尝试给SelectedValue赋一个不存在的值,WebForms会静默忽略,不回显也不报错。排查问题时容易一头雾水。处理方法是给下拉框补一个空的默认项,或者统一用FindByValue判断是否存在再赋值。

3.3 从ArrayList到ViewState存储的序列化细节

WebForms页面因回发机制而特殊,你需要在往返过程中保住一些数据。老代码里最常见的做法是把对象塞进ViewState,然后在下次回发时读出来。配合ArrayList的写法大概是:

private ArrayList SelectedProductList { get { if (ViewState["SelectedProducts"] == null) { return new ArrayList(); } return (ArrayList)ViewState["SelectedProducts"]; } set { ViewState["SelectedProducts"] = value; } }

这套代码在数据量小的时候跑得很好,数据量一大就原形毕露。因为ViewState默认序列化用的是LOSFormatter,整个页面的ViewState最终会以base64字符串的形式藏在页面隐藏字段里。每次回发,服务端要把这个字符串解析回来,页面加载完又整体序列化一遍塞回去。你往ViewState里放一个包含几百个对象的ArrayList,页面体积直接从20KB涨到100KB以上,用户每次点按钮都要多传几十KB的数据,打开速度肉眼可见地变慢。

还有序列化本身的硬性规则:放进ArrayList里的对象必须可序列化。一旦类定义缺少[Serializable]标记,页面运行到ViewState序列化时直接抛异常,特别隐蔽,因为页面加载阶段本身不报错,回发时它才炸。

所以我对ViewState存ArrayList的实际建议只有一个:数据量小(十来个对象以内)随便用,数据量大就别硬塞。轻量数据用List<string>、自定义类型做精简序列化都行,或者干脆改成重新查数据库。如果确实要存比较大的数据,优先考虑Session,起码它不会跟着页面跑来跑去,性能压力小得多。Session也不是完全没有代价,内存占用、用户并发一高服务器内存就紧张,小项目能用,大并发下要另想方案。

4. 常见问题与排查技巧实录

4.1 经典异常:类型转换失败的排查思路

老项目里跑着跑着冒出InvalidCastException太常见了。光我处理过的就有不下十种变种。最典型的是下面这种堆栈:

System.InvalidCastException: Specified cast is not valid. at System.Collections.ArrayList.Add(Object value) at ...

遇到这种要冷静分析,不要盯着ArrayList本身看,要意识到问题几乎总是出在"谁往列表里放了不匹配的元素"或者"谁从列表里取元素强转成了错误的类型"。排查步骤我建议固定为三步走:

第一步,先看ArrayList是在哪个方法、哪个模块被填充的。是页面直接Add的,还是业务层构造好传上来的。把Add的每个元素都检查一遍。

第二步,确认取用处的类型。看那段代码把元素转成了什么类型,是string还是自定义对象,再回头对照Add进去的是什么类型。

第三步,特别注意循环里反复使用的列表。有时候是第一次循环正常,第二次循环数据源变了,元素类型变了,代码还按老类型取,直接爆炸。

我刚才提到了老项目中混用ArrayList和普通数组的情况。比如业务层返回ArrayList,为了兼容故意用object[]接了,再一个个强转。这时候如果ArrayList里夹杂了DBNull、null这些值,强转直接挂。老代码里这是重灾区。处理方式是Add之前统一判断是否DBNull,或者取值处用Convert而不是直接强转。

// 推荐 object rawValue = reader["CategoryName"]; string name = rawValue == DBNull.Value ? string.Empty : Convert.ToString(rawValue); // 不推荐,遇到DBNull直接抛异常 string name = (string)reader["CategoryName"];

4.2 ArrayList作为ViewState数据的性能崩塌问题

前面提过ArrayList和ViewState搭配会让页面体积膨胀,这里展开聊聊我实际踩过的案例。

有次给一个老订单系统做性能优化,页面加载倒是快,就是点按钮回发特别慢。打开页面源码一看,ViewState那个隐藏字段有差不多160KB。顺着代码一查,发现一个页面把购物车明细(CartItem列表,大概30个对象)在下单前整个存在ViewState里,用的正是ArrayList,每次回发都跟着传一遍。

即使只有30个对象,因为ArrayList内部存储的是object引用,序列化结果里会附带很多程序集信息、类型全名,再加上base64编码膨胀,体积直接失控。后来我把这个ViewState改成Session存储,页面回发体积从160KB降到20KB左右,操作响应速度立竿见影。

这里给所有WebForms后人一个通用排查法:页面变慢先别怀疑数据库,用浏览器开发者工具看一下页面体积,如果隐藏的__VIEWSTATE字段很大,八九不离十就是ViewState里塞了不合适的对象。能做以下调整就不要犹豫:

  • 能重查数据库就重查,别用ViewState缓存
  • 必须存的状态优先用Session或Cache
  • 如果非要ViewState,尽量存简单类型,比如int[]、List<int>而不是自定义对象集合

4.3 与List 混用的转换陷阱

老项目里经常出现ArrayList和List<T>并存的情况,比如新写的代码用List<T>,旧接口传来的是ArrayList。这两者之间没有直接隐式转换,直接赋值编译就报错了。常见的绕路解法是:

ArrayList oldList = GetOldList(); List<CategoryInfo> newList = oldList.Cast<CategoryInfo>().ToList();

这个转换本身没问题,但有一个隐藏陷阱:Cast<T>()遇到null元素照样抛异常。如果ArrayList里混有DBNull.Value、null这些脏数据,Cast<T>()当场翻车。转换前先过滤一遍:

ArrayList oldList = GetOldList(); List<CategoryInfo> newList = oldList .OfType<CategoryInfo>() .ToList();

OfType<T>()会自动跳过类型不匹配的元素,比Cast<T>()容错性强得多。这个细节我在实际代码评审里反复提过,因为很多人喜欢无脑用Cast<T>()。

反向转换也不难,但如果只是把List<T>传给老接口,用new ArrayList(newList)构造器,底层会复制引用,效率和可读性都不错。注意这个复制也是浅拷贝,修改值类型不影响原集合,修改引用类型仍然联动。

4.4 线程安全这个老生长谈的坑

ArrayList的线程安全性,文档里写得很清楚:内部方法不是线程安全的。多个线程同时读写同一个ArrayList实例时,行为未定义。但在WebForms项目里,因为页面生命周期往往绑定在单个请求线程上,这个风险不像其他并发系统那么突出。前提是你没有将ArrayList放在Application(全局缓存)或静态字段里共享。

真正需要警惕的场景是:多个用户同时触发某个全局ArrayList的写操作,比如一个静态的缓存列表。这种情况必须加锁,否则轻则数据错乱,重则直接把进程给搞崩——曾经有个项目在缓存列表并发Add时,直接跑出IndexOutOfRangeException,排查了半天才定位到是ArrayList扩容时多个线程同时触发,导致内部数组越界。

如果要保证线程安全,老项目里有两种常见方案:

// 方式一:加lock锁 private static readonly object _lock = new object(); private static ArrayList _cacheList = new ArrayList(); public static void AddToCache(object item) { lock (_lock) { _cacheList.Add(item); } } // 方式二:用Synchronized包装 ArrayList syncList = ArrayList.Synchronized(new ArrayList()); syncList.Add(item);

方式二比方式一简单,但它是粗粒度锁,所有方法都会上锁,性能比不上让你自己精确控制锁范围。我更推荐方式一,或者干脆把全局缓存改成ConcurrentBag<T>这类并发集合,彻底把锁的问题交给框架。

4.5 常见问题速查表

为了方便排查,我把平时积累的ArrayList相关坑整理成了表格:

症状根因解决方案
InvalidCastException元素类型不匹配检查Add处与取用处类型是否一致,优先使用Convert
页面回发异常、ViewState解析失败ArrayList中的对象未标记[Serializable]给对象类补充[Serializable]特性
页面Body体积异常大大量数据存ViewState改为Session或重新查询数据库
数据莫名其妙"变"了Clone浅拷贝导致引用共享手动深拷贝对象或改用值类型
下拉框空选、不显示默认项绑定后SelectedValue不存在绑定前补充默认项或先FindByValue判断
全局列表并发操作异常ArrayList非线程安全加锁或换并发集合
频繁头部插入导致卡顿Insert(0, item)造成大量元素移动改为Add+反转,或换LinkedList

这张表不是全量异常清单,但覆盖了老项目里最常见的80%。凡是你接手一个WebForms项目,建议先把代码里所有ArrayList出现的位置全部列出来,逐个对着表排查一遍,防患于未然。

5. 如何优雅地处理老代码中的ArrayList

5.1 渐进式替换策略

现实情况很残酷:你没法一夜之间把整个系统的ArrayList全换成List<T>——除非你愿意承担巨大的回归风险。我处理老项目的基本思路是渐进式替换,优先级从高到低排列。

最高优先级是正在修改、即将新增功能的代码。新写的接口统一用List<T>,坚决不用ArrayList。这样新代码不会继续积累技术债。中间过渡态允许把ArrayList转成List<T>,后面再接回老接口时再转回去,保持接口层的兼容。最低优先级是那些稳定运行、无人触碰的老代码——不要为了"代码洁癖"去动它,不动就不会出错。

这个策略看起来保守,但长期效果最好。实际执行中我建议配合单元测试或者至少配一套手工回归清单,每替换一个模块就过一遍涉及的页面。毕竟WebForms的页面生命周期校验事件、ViewState、回发机制全都有连带关系,改动一处可能牵扯到页面某个隐藏字段。

5.2 小技巧:用扩展方法平滑过渡

为了减轻替换过程中的摩擦,我推荐一个实用做法:给ArrayList写几个扩展方法,把它们快速包装成强类型列表。

public static class ArrayListExtensions { public static List<T> ToList<T>(this ArrayList arrayList) { return arrayList == null ? new List<T>() : arrayList.OfType<T>().ToList(); } public static ArrayList ToArrayList<T>(this List<T> list) { return list == null ? new ArrayList() : new ArrayList(list); } }

这样在老接口和新代码之间倒数据时,代码会清爽很多:

List<CategoryInfo> categories = GetCategoryList().ToList<CategoryInfo>(); // 新的业务逻辑用 categories 操作 // 需要调用老接口时再转回去 SaveCategoryList(categories.ToArrayList());

当然这层封装只是把类型转换藏起来,底层的数据复制和遍历开销依然存在,但是对老项目来说,可读性和可维护性的提升远远超过这点性能损耗。如果以后系统重构彻底,这个扩展类直接删掉就行,不影响任何业务逻辑。

5.3 什么时候必须当场处理

有一种情况没有缓冲余地:代码Review时发现新代码在往全局静态或缓存里放ArrayList并且有跨线程读写可能,这必须当场改掉,不能留到以后。原因前面说过,并发环境下ArrayList的崩溃是随机的、难复现的,一旦出问题排查成本极高。这种情况应该当机立断换成ConcurrentBag<T>或List<T>加锁。

另一种情况是对象要长期存入ViewState或者Session而且数据量会在运行时增长到不可控程度。这种必须当场改设计,比如存储前先压缩、只存必要字段、或者换存储介质。既然已经预见到数据量增长,就不要给未来埋雷了。

归根结底,ArrayList本身不是恶魔,它只是一个属于旧时代的工具。在WebForms的世界里,它帮你完成了那个年代能完成的所有事情。真正决定代码质量的,从来不是用了什么集合类,而是你是否理解它背后的行为模式、边界条件和性能代价。老项目不可怕,可怕的是你稀里糊涂地跟它共处多年,直到线上事故爆发才意识到问题出在哪一行。

最后分享一个我自己的操作习惯:每次接手新老项目,第一件事就是在解决方案里全局搜索ArrayList,把结果整理成一个清单,标注每个位置的数据流向和风险点。这份清单就是你后续所有重构和维护工作的地图。地图在手,你就不会在一个看似不应该出问题的地方,被一个看似弱不经风的类型搞得焦头烂额。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询