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查询每次只匹配单个品类,没有做合并查询
直接按下面改:
- 先做一层品类映射,把模型字段、数据库存储的品类名称对应上,拿到用户画像后先筛出所有偏好值大于5的品类列表
- Firestore自带
whereIn查询条件,可以一次查出categoryP字段在目标列表里的所有商品,注意这个条件最多支持传入10个匹配值,如果你符合条件的品类超过10个,分批查询后把结果合并即可 - 整个推荐页只写一次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
相关产品推荐
相关产品推荐

