资讯动态

WebForms老项目中的ArrayList:机制解析与渐进式重构指南

发布时间:2026/10/8 19:09:10 来源:尧图企业网站定制
接手过几个老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泛型ListT才以替代者的姿态出现。问题在于WebForms项目的生命周期太长很多系统从2005年左右上线中途只做功能迭代不做技术重构。这几年间积累的代码库要么直接写ArrayList要么封装在业务层往外抛。哪怕后来引入了ListT老代码也懒得专门去改——毕竟在页面后置代码里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有几个地方特别容易跟ListT搞混需要单独提一下。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万条记录里频繁读取数值型字段每条数据都走一次装箱拆箱消耗不可忽略。泛型ListT之所以能替代ArrayList核心就是它解决了这两个问题——类型在编译期就固定了不需要运行时转型值类型也不会装箱拆箱。不过说句公道话如果你只是在一个WebForm页面上关联几十条数据显示到下拉框ArrayList的性能损失低到可以忽略。真正的性能杀手出现在大数据量和循环拼接上那种场景一定要优先考虑ListT或者DictionaryTKey, 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的实际建议只有一个数据量小十来个对象以内随便用数据量大就别硬塞。轻量数据用Liststring、自定义类型做精简序列化都行或者干脆改成重新查数据库。如果确实要存比较大的数据优先考虑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[]、Listint而不是自定义对象集合4.3 与List 混用的转换陷阱老项目里经常出现ArrayList和ListT并存的情况比如新写的代码用ListT旧接口传来的是ArrayList。这两者之间没有直接隐式转换直接赋值编译就报错了。常见的绕路解法是ArrayList oldList GetOldList(); ListCategoryInfo newList oldList.CastCategoryInfo().ToList();这个转换本身没问题但有一个隐藏陷阱CastT()遇到null元素照样抛异常。如果ArrayList里混有DBNull.Value、null这些脏数据CastT()当场翻车。转换前先过滤一遍ArrayList oldList GetOldList(); ListCategoryInfo newList oldList .OfTypeCategoryInfo() .ToList();OfTypeT()会自动跳过类型不匹配的元素比CastT()容错性强得多。这个细节我在实际代码评审里反复提过因为很多人喜欢无脑用CastT()。反向转换也不难但如果只是把ListT传给老接口用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);方式二比方式一简单但它是粗粒度锁所有方法都会上锁性能比不上让你自己精确控制锁范围。我更推荐方式一或者干脆把全局缓存改成ConcurrentBagT这类并发集合彻底把锁的问题交给框架。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. 如何优雅地处理老代码中的ArrayList5.1 渐进式替换策略现实情况很残酷你没法一夜之间把整个系统的ArrayList全换成ListT——除非你愿意承担巨大的回归风险。我处理老项目的基本思路是渐进式替换优先级从高到低排列。最高优先级是正在修改、即将新增功能的代码。新写的接口统一用ListT坚决不用ArrayList。这样新代码不会继续积累技术债。中间过渡态允许把ArrayList转成ListT后面再接回老接口时再转回去保持接口层的兼容。最低优先级是那些稳定运行、无人触碰的老代码——不要为了代码洁癖去动它不动就不会出错。这个策略看起来保守但长期效果最好。实际执行中我建议配合单元测试或者至少配一套手工回归清单每替换一个模块就过一遍涉及的页面。毕竟WebForms的页面生命周期校验事件、ViewState、回发机制全都有连带关系改动一处可能牵扯到页面某个隐藏字段。5.2 小技巧用扩展方法平滑过渡为了减轻替换过程中的摩擦我推荐一个实用做法给ArrayList写几个扩展方法把它们快速包装成强类型列表。public static class ArrayListExtensions { public static ListT ToListT(this ArrayList arrayList) { return arrayList null ? new ListT() : arrayList.OfTypeT().ToList(); } public static ArrayList ToArrayListT(this ListT list) { return list null ? new ArrayList() : new ArrayList(list); } }这样在老接口和新代码之间倒数据时代码会清爽很多ListCategoryInfo categories GetCategoryList().ToListCategoryInfo(); // 新的业务逻辑用 categories 操作 // 需要调用老接口时再转回去 SaveCategoryList(categories.ToArrayList());当然这层封装只是把类型转换藏起来底层的数据复制和遍历开销依然存在但是对老项目来说可读性和可维护性的提升远远超过这点性能损耗。如果以后系统重构彻底这个扩展类直接删掉就行不影响任何业务逻辑。5.3 什么时候必须当场处理有一种情况没有缓冲余地代码Review时发现新代码在往全局静态或缓存里放ArrayList并且有跨线程读写可能这必须当场改掉不能留到以后。原因前面说过并发环境下ArrayList的崩溃是随机的、难复现的一旦出问题排查成本极高。这种情况应该当机立断换成ConcurrentBagT或ListT加锁。另一种情况是对象要长期存入ViewState或者Session而且数据量会在运行时增长到不可控程度。这种必须当场改设计比如存储前先压缩、只存必要字段、或者换存储介质。既然已经预见到数据量增长就不要给未来埋雷了。归根结底ArrayList本身不是恶魔它只是一个属于旧时代的工具。在WebForms的世界里它帮你完成了那个年代能完成的所有事情。真正决定代码质量的从来不是用了什么集合类而是你是否理解它背后的行为模式、边界条件和性能代价。老项目不可怕可怕的是你稀里糊涂地跟它共处多年直到线上事故爆发才意识到问题出在哪一行。最后分享一个我自己的操作习惯每次接手新老项目第一件事就是在解决方案里全局搜索ArrayList把结果整理成一个清单标注每个位置的数据流向和风险点。这份清单就是你后续所有重构和维护工作的地图。地图在手你就不会在一个看似不应该出问题的地方被一个看似弱不经风的类型搞得焦头烂额。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑