返回

Java ArrayList 高效过滤技巧:告别卡顿 优化性能

java

Java ArrayList 高效过滤:告别卡顿,提速技巧

处理 ArrayList 数据时,常常需要根据特定条件筛选出想要的内容。如果你的列表数据量不大,直接写个 for 循环可能感觉挺方便。但当数据量飙升,比如几百上千条甚至更多,你会发现 App 界面开始响应迟钝,甚至出现卡顿。就算你把它扔进 AsyncTask 或者其他后台线程,也只是缓解了 UI 卡顿,过滤本身的效率问题依然存在。

就像下面这个场景,有一个 DateAndNames 类,存储了日期(日、月、年)和名字:

public class DateAndNames {
    int day;
    int month;
    int year;
    String name;

    // 构造函数
    public DateAndNames(int day, int month, int year, String name) {
        this.day = day;
        this.month = month;
        this.year = year;
        this.name = name;
    }

    // Getters and Setters (省略部分)
    public int getMonth() {
        return month;
    }

    public int getYear() {
        return year;
    }
    // ... 其他 getter/setter ...
}

数据可能从数据库加载到一个 ArrayList

ArrayList<DateAndNames> list = hand.getData(); // hand 是数据库操作类实例

然后,你需要根据指定的月份和年份来过滤这个列表:

public ArrayList<DateAndNames> filterTheList(int month , int year){
    list = hand.getData(); // 每次过滤都重新获取数据?这里可能也有优化空间
    ArrayList<DateAndNames> filteredList = new ArrayList<DateAndNames>();

    // 经典的 for 循环过滤
    for (int i = 0; i < list.size(); i++) {
        DateAndNames currentItem = list.get(i);
        if(currentItem.getMonth() == month && currentItem.getYear() == year){
            // 注意:这里创建新对象可能不是必须的,除非后续需要修改
            // 如果只是为了过滤展示,可以直接添加 currentItem
            DateAndNames data = new DateAndNames(
                    currentItem.getDay(),
                    currentItem.getMonth(),
                    currentItem.getYear(),
                    currentItem.getName());
            filteredList.add(data);
        }
    }
    return filteredList;
}

list 里有几百条数据时,这个 for 循环确实会拖慢速度。问题出在哪?咱们来分析分析。

问题分析:为什么 for 循环会慢?

for 循环过滤本身是一种直接且易于理解的方式。它的核心操作是遍历

  1. 线性扫描 (O(N) 复杂度) :它需要逐一检查 ArrayList 中的每一个元素。如果列表有 N 个元素,它就要执行 N 次比较操作。当 N 增大时,总耗时也随之线性增加。300 条数据意味着 300 次条件判断和潜在的对象创建、添加操作。虽然单次操作很快,累积起来就不可忽视了。
  2. 内存分配(潜在) :在原代码的循环体内部,new DateAndNames(...) 会创建新的对象。虽然对象创建本身在现代 JVM 上效率不错,但在循环中频繁创建大量小对象,也可能给垃圾回收带来压力,间接影响性能。如果 DateAndNames 对象是不可变的,或者过滤后不需要修改原对象,可以直接添加 list.get(i)filteredList,避免不必要的对象创建。
  3. UI 线程阻塞 :如果这个过滤操作直接在主线程(UI 线程)执行,并且耗时稍长(比如超过 16 毫秒,即一帧的时间),用户就会感觉到界面卡顿。虽然使用 AsyncTask 等可以把任务放到后台,避免 ANR (Application Not Responding),但过滤本身的速度并没有变快,用户可能需要等待更长时间才能看到过滤结果。

用户也尝试了在数据库层面进行过滤:

public ArrayList<DateAndNames> getData(int month ,int year,String name){
    open(); // 打开数据库连接
    ArrayList<DateAndNames> list = new ArrayList<DateAndNames>();
    // 使用查询条件
    Cursor c = myDb.query(TABLE_DAY, null, "name= ? and month = ? and year = ?",
                         new String[] {name, String.valueOf(month), String.valueOf(year)}, // 注意类型转换
                         null, null, null);
    while (c.moveToNext()) {
        // 注意:这里列索引似乎和 DateAndNames 构造函数不完全对应,需要确认数据库表结构
        // 假设 c.getInt(0) 是 day, c.getString(1) 是 name, c.getInt(2) 是 month, c.getInt(3) 是 year
        // 但构造函数是 DateAndNames(int day, int month, int year, String name)
        // 应该像这样,或者根据实际列名获取索引:
         int dayIndex = c.getColumnIndexOrThrow("day_column_name"); // 假设列名是 day_column_name
         int monthIndex = c.getColumnIndexOrThrow("month");
         int yearIndex = c.getColumnIndexOrThrow("year");
         int nameIndex = c.getColumnIndexOrThrow("name");

        DateAndNames resultData = new DateAndNames(
                c.getInt(dayIndex),       // 日
                c.getInt(monthIndex),     // 月
                c.getInt(yearIndex),      // 年
                c.getString(nameIndex)    // 名字
            );
        list.add(resultData);
    }
    c.close(); // 关闭 Cursor
    close();   // 关闭数据库连接
    return list;
}

理论上,数据库查询过滤应该远快于在内存中遍历 ArrayList。如果这个方法仍然慢,通常原因在于:

  • 数据库没有建立索引 :这是最常见的原因。如果没有在 name, month, year 这些用于 WHERE 条件的列上创建索引,数据库引擎也需要进行全表扫描,效率和 for 循环差不多,甚至因为 I/O 操作可能更慢。
  • 频繁开关数据库连接 :如果在每次调用 getData 时都 open()close() 数据库连接,这个开销可能很大。最好使用单例模式或者依赖注入来管理数据库帮助类(如 SQLiteOpenHelper),并让它管理数据库连接的生命周期。
  • 数据量确实极大 :即使有索引,如果匹配结果的数据量本身非常庞大(比如成千上万条),从 Cursor 组装成 ArrayList<DateAndNames> 对象也需要时间。

高效过滤方案

了解了原因,咱们来看看几种提速的方法。

方案一:使用 Java 8 Streams API (推荐用于内存过滤)

如果数据已经加载到内存中的 ArrayList,并且你的项目环境支持 Java 8 或更高版本(Android 项目需要配置),Stream API 是一个更现代、更简洁,有时也更高效的选择。

  • 原理和作用:
    Stream API 提供了一种声明式的方式来处理集合数据。它允许你链式调用一系列操作(如 filter, map, collect),这些操作可以被优化执行,甚至并行处理。filter 操作会根据你提供的条件(Lambda 表达式)筛选元素,collect 操作则将结果收集到一个新的集合中。

  • 代码示例:

    import java.util.stream.Collectors;
    
    public ArrayList<DateAndNames> filterTheListWithStream(int month, int year) {
        // 假设 list 已经包含了所有数据
        // list = hand.getData(); // 如果需要,先获取数据
    
        // 使用 Stream API 进行过滤
        ArrayList<DateAndNames> filteredList = list.stream() // 1. 获取 Stream
                .filter(item -> item.getMonth() == month && item.getYear() == year) // 2. 应用过滤条件
                .collect(Collectors.toCollection(ArrayList::new)); // 3. 收集结果到新的 ArrayList
    
        // 如果只需要 List 接口,可以更简洁:
        // List<DateAndNames> filteredList = list.stream()
        //       .filter(item -> item.getMonth() == month && item.getYear() == year)
        //       .collect(Collectors.toList());
    
        return filteredList;
    }
    

    这段代码更简洁易读。filter 方法接收一个 Predicate(一个返回 boolean 的函数式接口),非常清晰地表达了过滤逻辑。

  • 进阶使用技巧:并行流 (Parallel Stream)
    如果数据量非常大(比如数十万条以上),并且过滤逻辑相对简单(CPU 密集型),可以尝试使用并行流来利用多核处理器加速。

    List<DateAndNames> filteredList = list.parallelStream() // 使用 parallelStream()
            .filter(item -> item.getMonth() == month && item.getYear() == year)
            .collect(Collectors.toList());
    

    注意: 并行流并非万能药。对于少量数据(几百条),其线程调度和合并结果的开销可能超过带来的好处。同时,要确保 Lambda 表达式中的操作是线程安全的。对于简单的过滤通常是安全的。

方案二:优化数据库查询 (强烈推荐,从源头解决)

把过滤操作交给数据库通常是最高效的方式,特别是数据量大时。数据库系统就是为快速检索数据而设计的。

  • 原理和作用:
    通过在 SQL 查询的 WHERE 子句中指定过滤条件,数据库可以直接利用索引(如果存在)快速定位到符合条件的记录,避免加载所有数据到内存中再进行筛选。这大大减少了数据传输量和内存消耗。

  • 操作步骤和代码示例:

    1. 确保创建了索引: 这是关键!你需要为经常用于查询条件的列(month, year, name)创建索引。可以在 SQLiteOpenHelperonCreateonUpgrade 方法中执行 SQL 命令。

      -- 为 month 和 year 列创建单独索引
      CREATE INDEX idx_month ON TABLE_DAY (month);
      CREATE INDEX idx_year ON TABLE_DAY (year);
      -- 或者,如果经常组合查询,创建一个复合索引可能更优
      CREATE INDEX idx_month_year_name ON TABLE_DAY (month, year, name);
      

      哪个索引更好取决于你的具体查询模式。如果总是按月和年过滤,idx_month_year 效果不错。如果也按名字过滤,idx_month_year_name 更合适。

    2. 改进的 getData 方法: 使用参数化查询,并确认列索引或列名正确。

      // 假设 DbHandler 或类似的类负责数据库交互
      public ArrayList<DateAndNames> getDataFilteredFromDB(int month, int year) {
          ArrayList<DateAndNames> list = new ArrayList<>();
          SQLiteDatabase db = null; // 从你的 DbHandler 获取可读数据库实例
          Cursor cursor = null;
          try {
              db = hand.getReadableDatabase(); // 获取数据库实例,避免频繁 open/close
      
              String tableName = "TABLE_DAY"; // 你的表名
              String[] columns = null; // 查询所有列,或者指定需要的列名数组以提高效率
              String selection = "month = ? AND year = ?"; // WHERE 子句
              String[] selectionArgs = { String.valueOf(month), String.valueOf(year) }; // 参数值
      
              cursor = db.query(
                      tableName,
                      columns,
                      selection,
                      selectionArgs,
                      null, // groupBy
                      null, // having
                      null  // orderBy
              );
      
              // 获取列索引,避免硬编码数字,更健壮
              int dayColIndex = cursor.getColumnIndexOrThrow("day"); // 使用实际列名
              int monthColIndex = cursor.getColumnIndexOrThrow("month");
              int yearColIndex = cursor.getColumnIndexOrThrow("year");
              int nameColIndex = cursor.getColumnIndexOrThrow("name");
      
              while (cursor.moveToNext()) {
                  DateAndNames data = new DateAndNames(
                          cursor.getInt(dayColIndex),
                          cursor.getInt(monthColIndex),
                          cursor.getInt(yearColIndex),
                          cursor.getString(nameColIndex)
                  );
                  list.add(data);
              }
          } catch (Exception e) {
              // 处理异常,例如记录日志
              Log.e("DBError", "Error querying filtered data", e);
          } finally {
              if (cursor != null) {
                  cursor.close(); // 必须关闭 Cursor
              }
              // 注意:通常不需要在这里关闭数据库 (db.close()),除非你的 DbHelper 设计如此。
              // 让 SQLiteOpenHelper 管理生命周期通常更好。
          }
          return list;
      }
      
  • 安全建议:

    • 防止 SQL 注入: 坚持使用 ? 占位符和 selectionArgs 数组的方式传递参数。绝不要直接将用户输入或变量拼接到 SQL 语句字符串中。上面代码示例已经是安全的参数化查询。
    • 资源管理: 务必在 finally 块中关闭 Cursor。数据库连接的管理最好交给 SQLiteOpenHelper
  • 进阶使用技巧:查询分析
    在 Android 开发中,可以使用 EXPLAIN QUERY PLAN SQL 命令来分析你的查询语句是否有效利用了索引。

    Cursor explainCursor = db.rawQuery("EXPLAIN QUERY PLAN SELECT * FROM TABLE_DAY WHERE month = ? AND year = ?", new String[]{String.valueOf(month), String.valueOf(year)});
    // 分析 explainCursor 的输出内容,查看是否使用了 SCAN TABLE(全表扫描)还是 USING INDEX
    // ... (具体分析逻辑) ...
    explainCursor.close();
    

方案三:使用第三方库 (如 Apache Commons Collections, Guava)

一些成熟的 Java 库也提供了方便的集合工具类,可以用于过滤。

  • 原理和作用:
    例如 Google 的 Guava 库,提供了 Collections2.filter 方法。它返回一个过滤后的集合视图 。这意味着它通常不会立即创建一个新的包含所有过滤结果的列表,而是在你迭代访问这个视图时动态进行过滤。这对于内存使用可能更友好,但过滤逻辑本身仍然需要遍历元素(除非底层集合有特殊支持)。

  • 代码示例 (Guava):

    1. 添加 Guava 依赖到你的 build.gradle 文件:

      implementation 'com.google.guava:guava:31.1-android' // 使用最新Android兼容版本
      
    2. 使用 Collections2.filter

      import com.google.common.collect.Collections2;
      import com.google.common.base.Predicate; // Guava 的 Predicate
      import java.util.Collection;
      import java.util.ArrayList;
      
      public ArrayList<DateAndNames> filterWithGuava(int month, int year) {
          // 假设 list 已经包含了所有数据
          // list = hand.getData();
      
          Predicate<DateAndNames> filterPredicate = item -> item.getMonth() == month && item.getYear() == year;
      
          // filter 返回的是一个 Collection 视图
          Collection<DateAndNames> filteredView = Collections2.filter(list, filterPredicate);
      
          // 如果你需要一个 ArrayList,可以基于视图创建新列表
          ArrayList<DateAndNames> filteredList = new ArrayList<>(filteredView);
      
          return filteredList;
      }
      
  • 注意事项:
    引入第三方库会增加 App 的体积。如果你的项目已经在使用 Guava 或 Apache Commons Collections,那么使用它们提供的工具类是自然的选择。如果仅为了过滤功能而引入,需要权衡利弊。Stream API 是 Java 标准库的一部分,无需额外依赖。

如何选择?

  1. 首选数据库过滤: 如果数据来自数据库,并且过滤条件可以直接映射到 SQL 的 WHERE 子句,强烈建议在数据库层面进行过滤 。确保相关列建立了索引。这是最高效、最节省内存的方式。
  2. 次选 Java Streams API: 如果数据已经在内存中(比如从网络获取,或者数据库过滤不便),或者需要在客户端做更复杂的、数据库难以实现的过滤逻辑,Java 8 Streams API 是现代、简洁、性能不错的选择。对于中等规模数据,它通常足够快。
  3. 备选第三方库: 如果项目已依赖 Guava 等库,可以考虑使用它们提供的过滤工具。
  4. 避免原始 for 循环(大数据量时): 对于可能增长到几百条以上的数据集,避免直接在主线程中使用简单的 for 循环进行过滤。即使放到后台线程,也考虑使用 Stream API 或优化数据源查询。

针对最初的问题——300 行数据过滤就很慢,并且数据库查询也慢——最可能的原因就是数据库没有为 monthyear (可能还有 name) 列建立索引。优先解决数据库索引问题,并使用方案二中的数据库查询方式,应该能看到最显著的性能提升。