You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Flutter中如何基于用户画像实现多分类商品推荐功能

需求说明
  • 基于用户画像数据库实现商品推荐功能:规则为用户某一品类的偏好值大于5时,就向用户展示对应品类的商品,效果示例如下。
  • 商品数据结构已完成定义,需要支持多品类同时展示:例如用户画像数据中camera值为6、car值为6时,需要同时展示汽车、相机两个品类的商品,匹配多品类示例数据的展示逻辑。
  • 现有代码仅完成了数据拉取和模型映射,仅能实现单个品类商品的展示,运行后只会展示第一个满足阈值条件的单品类商品,无法支持多品类商品同时展示。
现有代码

用户画像拉取与初始页面逻辑

ProfileModel profileModel = ProfileModel();

@override
void initState(){
  super.initState();
  FirebaseFirestore.instance
  .collection('userprofiling')
  .doc(user!.uid)
  .get()
  .then((value) {
    profileModel = ProfileModel.fromMap(value.data());
    setState(() {});
  });
}

Widget build(BuildContext context) {
  acc = profileModel.accessories!;
  ant = profileModel.antique!;
  cam = profileModel.camera!;
  car = profileModel.car!;
  com = profileModel.computer!;
  fur = profileModel.furniture!;
  game= profileModel.game!;
  lap = profileModel.laptop!;
  men = profileModel.men!;
  moto = profileModel.moto!;
  mus = profileModel.music!;
  pho = profileModel.phone!;
  spe = profileModel.speaker!;
  spo = profileModel.sport!;
  toy = profileModel.toy!;
  tv = profileModel.tv!;
  women = profileModel.women!;
    if(acc > 5){
      return Scaffold(
          appBar: AppBar(title: Text("Recommendation"),
            centerTitle: true,
          ),
          body: StreamBuilder(
              stream:FirebaseFirestore.instance.collection('products').where("categoryP", isEqualTo: "Accessories").snapshots(),
              builder: (context,AsyncSnapshot<QuerySnapshot> streamSnapshot){
                if(streamSnapshot.hasData){
                  return GridView.builder(gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
                      maxCrossAxisExtent: 200,
                      childAspectRatio: 2/3,
                      crossAxisSpacing: 15,
                      mainAxisSpacing: 15),
                      itemCount: streamSnapshot.data!.docs.length,
                      itemBuilder: (context, index){
                        final DocumentSnapshot documentSnapshot =
                        (streamSnapshot.data!.docs[index]);
                        DateTime dt = (documentSnapshot['endDateTime'] as Timestamp).toDate();
                        return buildImageCard(documentSnapshot['imageURL'][0], documentSnapshot['nameP'], documentSnapshot['startPrice'], dt, documentSnapshot['id']);
                      }
                  );
                }
                return Center(
                  child: CircularProgressIndicator(),
                );
              }
          )
      );
    }
    if(ant > 5){
      return Scaffold(
          appBar: AppBar(title: Text("Recommendation"),
            centerTitle: true,
          ),
          body: StreamBuilder(
              stream:FirebaseFirestore.instance.collection('products').where("categoryP", isEqualTo: "Antique").snapshots(),
              builder: (context,AsyncSnapshot<QuerySnapshot> streamSnapshot){
                if(streamSnapshot.hasData){
                  return GridView.builder(gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
                      maxCrossAxisExtent: 200,
                      childAspectRatio: 2/3,
                      crossAxisSpacing: 15,
                      mainAxisSpacing: 15),
                      itemCount: streamSnapshot.data!.docs.length,
                      itemBuilder: (context, index){
                        final DocumentSnapshot documentSnapshot =
                        (streamSnapshot.data!.docs[index]);
                        DateTime dt = (documentSnapshot['endDateTime'] as Timestamp).toDate();
                        return buildImageCard(documentSnapshot['imageURL'][0], documentSnapshot['nameP'], documentSnapshot['startPrice'], dt, documentSnapshot['id']);
                      }
                  );
                }
                return Center(
                  child: CircularProgressIndicator(),
                );
              }
          )
      );
    }
}

商品图片加载与卡片组件

Future<String> downloadURL(String images) async{
  url = await st.ref('products/$images').getDownloadURL();
  // for(int i = 0; i<images.length; i++){
  //   url.add(await st.ref('products/${images[i]}').getDownloadURL());
  // }
  return url;
}
Widget buildImageCard(String image,String name,String price, DateTime dt,String id) => FutureBuilder( future: downloadURL(image),
  builder: (BuildContext context, AsyncSnapshot snapshot)  {
    if(snapshot.hasData){
        return Card(
          clipBehavior: Clip.antiAlias,
          shape: RoundedRectangleBorder(
            borderRadius: BorderRadius.circular(15),
          ),
          child: Column(
            children: [
              Stack(
                alignment: Alignment.center,
                children: [
                  Ink.image(
                    image: NetworkImage(snapshot.data),
                    child: InkWell(
                      onTap: () {
                        Navigator.of(context).push(MaterialPageRoute(
                            builder: (context) => DetailScreen(
                              productId: id,
                            )));
                      },
                    ),
                    height: 170,
                    fit: BoxFit.cover,
                  ),
                ],
              ),

              Expanded(
                child: Container(
                  alignment: Alignment.center,
                  child: Padding(
                    padding: EdgeInsets.all(4).copyWith(bottom: 0),
                    child: Text(
                      name,
                      style: TextStyle(fontSize: 16),
                    ),
                  ),
                ),
              ),
              Expanded(
                child: Container(
                  alignment: Alignment.center,
                  child: Padding(
                    padding: EdgeInsets.all(6).copyWith(bottom: 0),
                    child: Text(
                      "RM $price",
                      style: TextStyle(fontSize: 16),
                    ),
                  ),
                ),
              ),
              SizedBox(height: 6),
              Expanded(
                child: CountDownText(
                  due: dt,
                  finishedText: "The Auction has End",
                  showLabel: true,
                  daysTextShort: "D ",
                  hoursTextShort: "H ",
                  minutesTextShort: "M ",
                  secondsTextShort: "S ",
                  style:
                  TextStyle(fontSize: 16, color: Colors.lightGreen),
                ),
              ),
              SizedBox(height: 2),
            ],
          ),
        );

        }

    return const Center(
      child: CircularProgressIndicator(),
    );

  },
);

用户画像模型类

class ProfileModel{
  int? accessories,antique,camera,car,computer,furniture,game,laptop,men,moto,music,phone,speaker,sport,toy,tv,women;

  ProfileModel({this.accessories,this.antique,this.camera
    ,this.car,this.computer,this.furniture,this.game
    ,this.laptop,this.men,this.moto,this.music,this.phone
    ,this.speaker,this.sport,this.toy,this.tv,this.women});

  factory ProfileModel.fromMap(map){
    return ProfileModel(
      accessories: map['accessories'],
      antique: map['antique'],
      camera: map['camera'],
      car: map['car'],
      computer: map['computer'],
      furniture: map['furniture'],
      game: map['game'],
      laptop: map['laptop'],
      men: map['men'],
      moto: map['moto'],
      music: map['music'],
      phone: map['phone'],
      speaker: map['speaker'],
      sport: map['sport'],
      toy: map['toy'],
      tv: map['tv'],
      women: map['women'],
    );
  }

  Map<String,dynamic> toMap(){
    return {
      'accessories': accessories,
      'antique': antique,
      'camera' : camera,
      'car': car,
      'computer': computer,
      'furniture': furniture,
      'game': game,
      'laptop': laptop,
      'men': men,
      'moto': moto,
      'music': music,
      'phone': phone,
      'speaker': speaker,
      'sport': sport,
      'toy': toy,
      'tv': tv,
      'women': women,

    };
  }

  Map<String, dynamic> toJson() =>{
    'accessories': accessories,
    'antique': antique,
    'camera' : camera,
    'car': car,
    'computer': computer,
    'furniture': furniture,
    'game': game,
    'laptop': laptop,
    'men': men,
    'moto': moto,
    'music': music,
    'phone': phone,
    'speaker': speaker,
    'sport': sport,
    'toy': toy,
    'tv': tv,
    'women': women,
  };

  static ProfileModel fromJson(Map<String, dynamic> json)=> ProfileModel(
    accessories: json['accessories'],
    antique: json['antique'],
    camera: json['camera'],
    car: json['car'],
    computer: json['computer'],
    furniture: json['furniture'],
    game: json['game'],
    laptop: json['laptop'],
    men: json['men'],
    moto: json['moto'],
    music: json['music'],
    phone: json['phone'],
    speaker: json['speaker'],
    sport: json['sport'],
    toy: json['toy'],
    tv: json['tv'],
    women: json['women'],

  );
}
解决方案

问题出在三点:

  • 串行if判断命中第一个符合条件的品类后就直接return整个页面,后面的品类判断根本不会执行
  • 每个品类单独写一套Scaffold+StreamBuilder+GridView的重复代码,逻辑上就无法支持多品类合并展示
  • Firestore查询每次只匹配单个品类,没有做合并查询

直接按下面改:

  1. 先做一层品类映射,把模型字段、数据库存储的品类名称对应上,拿到用户画像后先筛出所有偏好值大于5的品类列表
  2. Firestore自带whereIn查询条件,可以一次查出categoryP字段在目标列表里的所有商品,注意这个条件最多支持传入10个匹配值,如果你符合条件的品类超过10个,分批查询后把结果合并即可
  3. 整个推荐页只写一次Scaffold和GridView,不要给每个品类单独写重复的页面代码

修改后的核心逻辑参考:

// 定义品类映射,关联模型字段和数据库存储的品类值
final Map<String, Map<String, dynamic>> categoryMap = {
  'accessories': {'dbValue': 'Accessories', 'prefKey': 'accessories'},
  'antique': {'dbValue': 'Antique', 'prefKey': 'antique'},
  'camera': {'dbValue': 'Camera', 'prefKey': 'camera'},
  'car': {'dbValue': 'Car', 'prefKey': 'car'},
  'computer': {'dbValue': 'Computer', 'prefKey': 'computer'},
  'furniture': {'dbValue': 'Furniture', 'prefKey': 'furniture'},
  'game': {'dbValue': 'Game', 'prefKey': 'game'},
  'laptop': {'dbValue': 'Laptop', 'prefKey': 'laptop'},
  'men': {'dbValue': 'Men', 'prefKey': 'men'},
  'moto': {'dbValue': 'Moto', 'prefKey': 'moto'},
  'music': {'dbValue': 'Music', 'prefKey': 'music'},
  'phone': {'dbValue': 'Phone', 'prefKey': 'phone'},
  'speaker': {'dbValue': 'Speaker', 'prefKey': 'speaker'},
  'sport': {'dbValue': 'Sport', 'prefKey': 'sport'},
  'toy': {'dbValue': 'Toy', 'prefKey': 'toy'},
  'tv': {'dbValue': 'TV', 'prefKey': 'tv'},
  'women': {'dbValue': 'Women', 'prefKey': 'women'},
};

Widget build(BuildContext context) {
  // 画像数据没加载完显示加载态
  if (profileModel.accessories == null) {
    return Scaffold(
      appBar: AppBar(title: Text("Recommendation"), centerTitle: true),
      body: Center(child: CircularProgressIndicator()),
    );
  }

  // 筛选所有偏好值>5的品类
  List<String> validCategories = [];
  final profileMap = profileModel.toMap();
  categoryMap.forEach((key, value) {
    if (profileMap[value['prefKey']] != null && profileMap[value['prefKey']] > 5) {
      validCategories.add(value['dbValue']);
    }
  });

  // 无符合条件的品类显示空状态
  if (validCategories.isEmpty) {
    return Scaffold(
      appBar: AppBar(title: Text("Recommendation"), centerTitle: true),
      body: Center(child: Text("暂无推荐商品")),
    );
  }

  // 一次查询所有符合品类的商品
  return Scaffold(
    appBar: AppBar(title: Text("Recommendation"), centerTitle: true),
    body: StreamBuilder(
      stream: FirebaseFirestore.instance
          .collection('products')
          .where("categoryP", whereIn: validCategories)
          .snapshots(),
      builder: (context, AsyncSnapshot<QuerySnapshot> streamSnapshot) {
        if (streamSnapshot.hasData) {
          return GridView.builder(
            padding: EdgeInsets.all(15),
            gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
                maxCrossAxisExtent: 200,
                childAspectRatio: 2/3,
                crossAxisSpacing: 15,
                mainAxisSpacing: 15
            ),
            itemCount: streamSnapshot.data!.docs.length,
            itemBuilder: (context, index) {
              final DocumentSnapshot documentSnapshot = streamSnapshot.data!.docs[index];
              DateTime dt = (documentSnapshot['endDateTime'] as Timestamp).toDate();
              return buildImageCard(
                documentSnapshot['imageURL'][0],
                documentSnapshot['nameP'],
                documentSnapshot['startPrice'].toString(),
                dt,
                documentSnapshot['id']
              );
            },
          );
        }
        return Center(child: CircularProgressIndicator());
      },
    ),
  );
}

额外修复一个隐藏bug:你原来的downloadURL方法里url用的是全局变量,多个商品卡片同时加载的时候会出现url互相覆盖、图片显示错乱的问题,把url改成方法内局部变量即可:

Future<String> downloadURL(String images) async{
  final url = await st.ref('products/$images').getDownloadURL();
  return url;
}

内容的提问来源于stack exchange,提问作者Nazhim

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.29 11:12:30