27、复杂查询:@RawQuery原始查询、子查询、JOIN查询、聚合函数

Room 的 DAO 注解确实好用,但说实话,遇到真正复杂的查询场景时,光靠 @Query 那套模板字符串有时候会让人抓狂。我早期做项目时,就碰到过一个报表需求——要跨三张表做聚合,还要动态拼接筛选条件。当时用 @Query 硬写了一个超长的 SQL,结果维护起来简直噩梦。

后来我学乖了。Room 其实给了我们好几把「重武器」:@RawQuery、子查询、JOIN 查询、聚合函数。今天咱们就把这些家伙挨个捋一遍。

27.1 @RawQuery:把 SQL 控制权完全交给你

@RawQuery@Query 最大的区别是什么?@Query 在编译时就固定了 SQL 模板,而 @RawQuery 允许你在运行时动态构建 SQL 语句。说白了,它就是一个「逃生舱」——当 Room 的静态检查满足不了你时,就用它。

核心区别:
  • @Query:编译时校验 SQL,性能更好,但不够灵活
  • @RawQuery:运行时执行,完全动态,但失去了编译期检查

来看一个实际例子。假设我们要做一个搜索功能,用户可以选择按「标题」「作者」「标签」来搜索,而且可以多条件组合。用 @Query 你得写 N 个方法,用 @RawQuery 一个就够了:

@Dao
interface ArticleDao {
    @RawQuery(observedEntities = [Article::class])
    fun searchArticles(query: SupportSQLiteQuery): List<Article>
}

// 调用时动态构建 SQL
fun buildSearchQuery(keyword: String, filterBy: String): SupportSQLiteQuery {
    val sql = when (filterBy) {
        "title"  -> "SELECT * FROM articles WHERE title LIKE ?"
        "author" -> "SELECT * FROM articles WHERE author LIKE ?"
        "tag"    -> "SELECT * FROM articles WHERE tag LIKE ?"
        else     -> "SELECT * FROM articles WHERE title LIKE ? OR author LIKE ?"
    }
    return SimpleSQLiteQuery(sql, arrayOf("%$keyword%"))
}
我的习惯:只在动态 SQL 场景下用 @RawQuery。如果查询条件是固定的,老老实实用 @Query,编译期就能帮你揪出 SQL 拼写错误。

27.2 子查询:嵌套查询的艺术

子查询说白了就是「查询里套查询」。Room 完全支持,你直接在 @Query 里写嵌套 SELECT 就行。我印象最深的一个场景是:查「最近一周内,评论数超过 10 条的热门文章」。这用子查询写起来特别自然:

@Query("""
    SELECT * FROM articles 
    WHERE id IN (
        SELECT article_id FROM comments 
        WHERE created_at > :weekAgo 
        GROUP BY article_id 
        HAVING COUNT(*) > :minCount
    )
""")
fun getHotArticles(weekAgo: Long, minCount: Int): List<Article>

为什么会这样?因为子查询把「筛选逻辑」和「主查询」分开了,代码读起来就像在描述业务需求。你想想看,如果不用子查询,你得先查评论表拿到 article_id 列表,再查文章表——多了一次网络往返(如果是跨进程)或者多了一次内存操作。

我曾经踩过的坑:子查询如果嵌套太深(三层以上),SQLite 的查询计划器可能会选错索引,导致性能骤降。我的建议是:超过两层子查询,就考虑拆成多个 DAO 方法,或者用 JOIN 替代。

27.3 JOIN 查询:把表连起来

JOIN 是关系型数据库的灵魂。Room 里 JOIN 查询的写法跟原生 SQL 一模一样。常用的有三种:

JOIN 类型 说明 使用场景
INNER JOIN 只返回匹配的行 查「有评论的文章」
LEFT JOIN 返回左表所有行,右表无匹配则补 NULL 查「所有文章及其评论数(包括没评论的)」
CROSS JOIN 笛卡尔积,慎用 极少用,除非你知道自己在做什么

来看一个 LEFT JOIN 的实战例子。我们要查「每篇文章的标题、作者、以及它的最新一条评论内容」:

data class ArticleWithLatestComment(
    @ColumnInfo(name = "title") val title: String,
    @ColumnInfo(name = "author") val author: String,
    @ColumnInfo(name = "latest_comment") val latestComment: String?
)

@Query("""
    SELECT a.title, a.author, c.content AS latest_comment
    FROM articles a
    LEFT JOIN comments c ON a.id = c.article_id
    WHERE c.id = (
        SELECT MAX(id) FROM comments WHERE article_id = a.id
    ) OR c.id IS NULL
""")
fun getArticlesWithLatestComment(): List<ArticleWithLatestComment>

这里用了 LEFT JOIN 加子查询来取「最新评论」。注意看,c.id IS NULL 这个条件是为了保留那些没有评论的文章——LEFT JOIN 的特性就是右表无匹配时补 NULL。

避坑指南:JOIN 查询返回的结果集,Room 需要你提供一个「平铺」的数据类(比如上面的 ArticleWithLatestComment)。不要想着用 @Relation 注解去映射 JOIN 结果,那是给嵌套关系用的,JOIN 的结果是扁平的。

27.4 聚合函数:COUNT、SUM、AVG、MAX、MIN

聚合函数在 Room 里用起来很简单,但有个细节很多人会忽略——返回值类型。来看几个典型用法:

@Dao
interface StatsDao {
    // 统计总数,返回 Int
    @Query("SELECT COUNT(*) FROM articles")
    fun getArticleCount(): Int

    // 统计平均值,返回 Double
    @Query("SELECT AVG(price) FROM products WHERE category = :cat")
    fun getAvgPrice(cat: String): Double

    // 分组统计,返回自定义数据类
    @Query("""
        SELECT category, COUNT(*) AS count, AVG(price) AS avg_price
        FROM products
        GROUP BY category
    """)
    fun getCategoryStats(): List<CategoryStat>
}

data class CategoryStat(
    val category: String,
    val count: Int,
    @ColumnInfo(name = "avg_price") val avgPrice: Double
)

嗯,这里要注意:COUNT(*) 返回的是 IntAVG 返回的是 Double。如果你用 SUM 对整数字段求和,Room 会返回 Long。这些类型映射是固定的,搞错了编译期就会报错。

我个人习惯把聚合查询的结果封装成单独的数据类,就像上面的 CategoryStat。这样代码结构清晰,而且 Room 会自动帮你做列名映射——只要 @ColumnInfo 写对了就行。

27.5 综合实战:一个带统计的仪表盘

最后咱们来一个综合案例。假设我们要做一个「文章仪表盘」,展示:

  • 文章总数
  • 最近 7 天新增文章数
  • 评论最多的前 5 篇文章
  • 每个分类的文章数

这个需求涉及了聚合函数、子查询、JOIN、以及 @RawQuery 的混合使用。我当时的做法是拆成四个 DAO 方法,然后在 Repository 层组装:

@Dao
interface DashboardDao {
    @Query("SELECT COUNT(*) FROM articles")
    fun getTotalCount(): Int

    @Query("SELECT COUNT(*) FROM articles WHERE created_at > :since")
    fun getRecentCount(since: Long): Int

    @Query("""
        SELECT a.title, COUNT(c.id) AS comment_count
        FROM articles a
        LEFT JOIN comments c ON a.id = c.article_id
        GROUP BY a.id
        ORDER BY comment_count DESC
        LIMIT 5
    """)
    fun getTopCommentedArticles(): List<ArticleCommentCount>

    @RawQuery(observedEntities = [Article::class])
    fun getCategoryStats(query: SupportSQLiteQuery): List<CategoryStat>
}

// 在 Repository 中动态构建分类统计的 SQL
fun getCategoryStats(): List<CategoryStat> {
    val sql = """
        SELECT category, COUNT(*) AS count
        FROM articles
        WHERE category IS NOT NULL
        GROUP BY category
        ORDER BY count DESC
    """
    return dashboardDao.getCategoryStats(SimpleSQLiteQuery(sql))
}
核心思路:把复杂的仪表盘拆成多个「原子查询」,每个查询只做一件事。这样既保持了 Room 的编译期检查优势,又通过 @RawQuery 保留了动态能力。你想想看,如果全部塞到一个 @RawQuery 里,代码会变成什么样?维护起来绝对想骂人。

好了,关于复杂查询的内容就这些。记住一个原则:能用 @Query 就别用 @RawQuery,能用 JOIN 就别用多层子查询,能用聚合函数就别在 Java/Kotlin 代码里手动算。Room 帮你做了很多事,但前提是你得把 SQL 写对。

Room 复杂查询知识体系 复杂查询 @RawQuery 运行时动态构建 SQL 子查询 SELECT 嵌套 SELECT JOIN 查询 INNER / LEFT / CROSS 聚合函数 COUNT / SUM / AVG / MAX / MIN 原则:能用 @Query 不用 @RawQuery,能用 JOIN 不用多层子查询

公众号:蓝海资料掘金营,微信 deep3321