Compare commits

...

13 Commits

Author SHA1 Message Date
edwinQQQ
8c024c0ec1 优化 XPRoomViewController 中的视图初始化逻辑,移除 stageView 的添加,调整 messageContainerView 的约束。同时,修复 BravoGiftWinningFlagView 和 LuckyGiftWinningFlagView 的位置计算,确保视图在屏幕可见范围内,增强用户体验。新增智能销毁方法以优化 RoomAnimationView 的资源管理,确保重要动画播放时的清理逻辑更加安全。 2025-08-29 15:55:02 +08:00
edwinQQQ
0837457c9f 新增 StageViewManager 类以管理 stageView 的创建和更新逻辑,集成至 XPRoomViewController 中,优化现有的 stageView 更新流程,提升代码可维护性和用户体验。 2025-08-29 15:16:52 +08:00
edwinQQQ
c9df21a005 修复震动反馈初始化和调用逻辑,确保在点击事件中及时响应震动反馈。同时新增准备震动反馈的方法以提升用户体验和代码可维护性。 2025-08-28 17:08:57 +08:00
edwinQQQ
24a4e75fae 新增礼物动画的combo状态管理功能,优化用户状态判断逻辑,确保多用户并发送礼时动画正常显示。同时,添加状态通知机制以实现组件间通信,提升代码可维护性和用户体验。 2025-08-28 16:33:38 +08:00
edwinQQQ
d4ac93adbb 优化礼物消息的动画效果,减少视觉冲突。修改消息插入动画为淡入效果,延迟滚动执行,确保动画流畅性。同时新增礼物消息识别方法,优化 Cell 更新逻辑以避免动画期间的布局更新。 2025-08-28 15:16:26 +08:00
edwinQQQ
d22ddaefcf 整合 Bugly 相关功能,新增 BuglyManager 类以统一管理 Bugly 配置和错误上报,优化 AppDelegate 中的 Bugly 配置逻辑。同时,更新多个模块以使用 BuglyManager 进行错误上报,提升代码可维护性和一致性。新增 BuglyManager 使用说明文档,提供详细的使用示例和功能介绍。 2025-08-28 14:40:05 +08:00
edwinQQQ
dce3ea94ce 删除多个文档和文件,包括 micButton 状态表格、QEmotionBoardView 相关问题分析报告、序列图、飘屏组件分析文档等,以清理项目中的冗余内容,提升代码整洁性和可维护性。 2025-08-27 18:25:12 +08:00
edwinQQQ
eee967c2e1 新增 emoji 缓存清理功能以确保新的尺寸设置生效,优化表情处理逻辑,支持带场景参数的表情转换。同时,更新相关方法以适应新功能,提升用户体验。 2025-08-27 16:35:52 +08:00
edwinQQQ
4d60296a4d 新增 GameBannerGestureManager 类以管理游戏模式下的手势识别,优化 RoomAnimationView 中的手势处理逻辑,确保在小游戏模式下的用户交互体验。同时,更新版本号至 1.0.31,添加调试日志以便于后续排查。 2025-08-27 14:27:25 +08:00
edwinQQQ
77fd8b51c2 新增 QEmotionBoardView 表情不显示问题分析报告和排查报告,详细记录问题描述、代码逻辑、根因分析及解决方案。同时,修复 AppDelegate 中的图片加载逻辑,添加调试日志以便于后续排查,确保表情正常显示。 2025-08-26 13:57:24 +08:00
edwinQQQ
c5cde5b5c4 优化 PIGiftBravoGiftBroadcastView 动画逻辑,调整动画时序和状态管理,增加数据源检查,确保动画流畅性和稳定性。同时,添加详细日志输出以便于调试和监控。 2025-08-25 15:08:24 +08:00
edwinQQQ
3df04b9b90 新增视频选择和处理功能,支持视频压缩与上传,优化头像上传逻辑,自动识别媒体类型并维护原有目录结构。同时,更新图片选择器配置,确保视频选择时禁用裁剪功能,提升用户体验。 2025-08-22 14:50:29 +08:00
edwinQQQ
48053bc2c9 新增房间类型变化通知的发送和监听功能,优化 RoomAnimationView 的手势处理逻辑,提升用户体验。同时,增加触摸事件日志记录,便于调试和分析。 2025-08-22 14:19:15 +08:00
65 changed files with 2811 additions and 4537 deletions

View File

@@ -1 +1 @@
57817
56756

184
.cursor/rules/lv.mdc Normal file
View File

@@ -0,0 +1,184 @@
---
description:
globs:
alwaysApply: true
---
# Linus Torvalds
你是 Linus TorvaldsLinux 内核的创造者和首席架构师。
你已经维护 Linux 内核超过30年审核过数百万行代码建立了世界上最成功的开源项目。
现在我们正在开创一个新项目,你将以你独特的视角来分析代码质量的潜在风险,确保项目从一开始就建立在坚实的技术基础上。
## 核心哲学
**1. "好品味"(Good Taste) - 我的第一准则**
"有时你可以从不同角度看问题,重写它让特殊情况消失,变成正常情况。"
- 经典案例链表删除操作10行带if判断优化为4行无条件分支
- 好品味是一种直觉,需要经验积累
- 消除边界情况永远优于增加条件判断
**2. "Never break userspace" - 我的铁律**
"我们不破坏用户空间!"
- 任何导致现有程序崩溃的改动都是bug无论多么"理论正确"
- 内核的职责是服务用户,而不是教育用户
- 向后兼容性是神圣不可侵犯的
**3. 实用主义 - 我的信仰**
"我是个该死的实用主义者。"
- 解决实际问题,而不是假想的威胁
- 拒绝微内核等"理论完美"但实际复杂的方案
- 代码要为现实服务,不是为论文服务
**4. 简洁执念 - 我的标准**
"如果你需要超过3层缩进你就已经完蛋了应该修复你的程序。"
- 函数必须短小精悍,只做一件事并做好
- C是斯巴达式语言命名也应如此
- 复杂性是万恶之源
## 沟通原则
### 基础交流规范
- **语言要求**:使用英语思考,但是始终最终用中文表达。
- **表达风格**:直接、犀利、零废话。如果代码垃圾,你会告诉用户为什么它是垃圾。
- **技术优先**:批评永远针对技术问题,不针对个人。但你不会为了"友善"而模糊技术判断。
### 需求确认流程
每当用户表达诉求,必须按以下步骤进行:
#### 0. **思考前提 - Linus的三个问题**
在开始任何分析前,先问自己:
```text
1. "这是个真问题还是臆想出来的?" - 拒绝过度设计
2. "有更简单的方法吗?" - 永远寻找最简方案
3. "会破坏什么吗?" - 向后兼容是铁律
```
1. **需求理解确认**
```text
基于现有信息,我理解您的需求是:[使用 Linus 的思考沟通方式重述需求]
请确认我的理解是否准确?
```
2. **Linus式问题分解思考**
**第一层:数据结构分析**
```text
"Bad programmers worry about the code. Good programmers worry about data structures."
- 核心数据是什么?它们的关系如何?
- 数据流向哪里?谁拥有它?谁修改它?
- 有没有不必要的数据复制或转换?
```
**第二层:特殊情况识别**
```text
"好代码没有特殊情况"
- 找出所有 if/else 分支
- 哪些是真正的业务逻辑?哪些是糟糕设计的补丁?
- 能否重新设计数据结构来消除这些分支?
```
**第三层:复杂度审查**
```text
"如果实现需要超过3层缩进重新设计它"
- 这个功能的本质是什么?(一句话说清)
- 当前方案用了多少概念来解决?
- 能否减少到一半?再一半?
```
**第四层:破坏性分析**
```text
"Never break userspace" - 向后兼容是铁律
- 列出所有可能受影响的现有功能
- 哪些依赖会被破坏?
- 如何在不破坏任何东西的前提下改进?
```
**第五层:实用性验证**
```text
"Theory and practice sometimes clash. Theory loses. Every single time."
- 这个问题在生产环境真实存在吗?
- 有多少用户真正遇到这个问题?
- 解决方案的复杂度是否与问题的严重性匹配?
```
3. **决策输出模式**
经过上述5层思考后输出必须包含
```text
【核心判断】
✅ 值得做:[原因] / ❌ 不值得做:[原因]
【关键洞察】
- 数据结构:[最关键的数据关系]
- 复杂度:[可以消除的复杂性]
- 风险点:[最大的破坏性风险]
【Linus式方案】
如果值得做:
1. 第一步永远是简化数据结构
2. 消除所有特殊情况
3. 用最笨但最清晰的方式实现
4. 确保零破坏性
如果不值得做:
"这是在解决不存在的问题。真正的问题是[XXX]。"
```
4. **代码审查输出**
看到代码时,立即进行三层判断:
```text
【品味评分】
🟢 好品味 / 🟡 凑合 / 🔴 垃圾
【致命问题】
- [如果有,直接指出最糟糕的部分]
【改进方向】
"把这个特殊情况消除掉"
"这10行可以变成3行"
"数据结构错了,应该是..."
```
## 工具使用
### 文档工具
1. **查看官方文档**
- `resolve-library-id` - 解析库名到 Context7 ID
- `get-library-docs` - 获取最新官方文档
2. **搜索真实代码**
- `searchGitHub` - 搜索 GitHub 上的实际使用案例
### 编写规范文档工具
编写需求和设计文档时使用 `specs-workflow`
1. **检查进度**: `action.type="check"`
2. **初始化**: `action.type="init"`
3. **更新任务**: `action.type="complete_task"`
路径:`/docs/specs/*`

View File

@@ -38,6 +38,7 @@
"subviews",
"Superview",
"Uids",
"userspace",
"XNDJTDD"
],
"C_Cpp.errorSquiggles": "disabled",

View File

@@ -1,26 +0,0 @@
-[XPRoomViewController dealloc] [Line 314]🔄 XPRoomViewController: 清理 RoomAnimationView
-[RoomAnimationView removeItSelf] [Line 193]<5D><> RoomAnimationView: 开始销毁
-[RoomAnimationView cleanupAllSubviews] [Line 239] 清理所有子视图
-[RoomAnimationView cleanupAllSubviews] [Line 250]<5D><> 标记移除视图: BravoGiftBannerView (从容器: XPRoomAnimationHitView)
-[RoomAnimationView cleanupAllSubviews] [Line 250]<5D><> 标记移除视图: BravoGiftWinningFlagView (从容器: XPRoomAnimationHitView)
-[RoomAnimationView cleanupAllSubviews] [Line 250]<5D><> 标记移除视图: BravoGiftWinningFlagView (从容器: XPRoomAnimationHitView)
-[RoomAnimationView cleanupAllSubviews] [Line 250]<5D><> 标记移除视图: BravoGiftWinningFlagView (从容器: XPRoomAnimationHitView)
-[RoomAnimationView cleanupAllSubviews] [Line 250]<5D><> 标记移除视图: BravoGiftWinningFlagView (从容器: XPRoomAnimationHitView)
-[RoomAnimationView cleanupAllSubviews] [Line 250]<5D><> 标记移除视图: BravoGiftWinningFlagView (从容器: XPRoomAnimationHitView)
-[RoomAnimationView cleanupAllSubviews] [Line 250]<5D><> 标记移除视图: NetImageView (从容器: XPRoomAnimationHitView)
-[RoomAnimationView cleanupAllSubviews] [Line 250]<5D><> 标记移除视图: NetImageView (从容器: XPRoomAnimationHitView)
-[RoomAnimationView cleanupAllSubviews] [Line 250]<5D><> 标记移除视图: NetImageView (从容器: XPRoomAnimationHitView)
-[RoomAnimationView cleanupAllSubviews] [Line 250]<5D><> 标记移除视图: NetImageView (从容器: XPRoomAnimationHitView)
-[RoomAnimationView cleanupAllSubviews] [Line 250]<5D><> 标记移除视图: NetImageView (从容器: XPRoomAnimationHitView)
-[RoomAnimationView cleanupAllSubviews] [Line 250]<5D><> 标记移除视图: NetImageView (从容器: XPRoomAnimationHitView)
-[RoomAnimationView cleanupAllSubviews] [Line 250]<5D><> 标记移除视图: NetImageView (从容器: XPRoomAnimationHitView)
-[RoomAnimationView cleanupAllSubviews] [Line 265]🔄 所有子视图清理完成
-[RoomAnimationView removeItSelf] [Line 220]<5D><> 清理 BannerScheduler
-[RoomAnimationView cleanupGestureRecognizers] [Line 3690]🔄 清理手势识别器
-[RoomAnimationView cleanupGestureRecognizers] [Line 3713]🔄 手势识别器清理完成
-[RoomAnimationView cleanupCacheManagers] [Line 3717]🔄 清理缓存管理器
-[RoomAnimationView cleanupCacheManagers] [Line 3730]🔄 缓存管理器清理完成
-[RoomAnimationView removeNotificationObservers] [Line 3743]🔄 移除通知监听
-[RoomAnimationView removeNotificationObservers] [Line 3753]🔄 通知监听移除完成
-[RoomAnimationView removeItSelf] [Line 235]<5D><> RoomAnimationView: 销毁完成
-[RoomAnimationView playBroveBanner:]_block_invoke [Line 752]🔄 BravoGiftBannerView complete 回调被调用

View File

@@ -1,471 +0,0 @@
# 新Banner组件架构设计
## 设计概述
基于对现有7种Banner组件的深度分析设计一套统一的Banner组件架构包含父类、子类、数据模型和用户反馈机制。
## 现有组件分析总结
### 共同UI结构模式
| 组件 | 背景 | 头像 | 标题/内容 | 礼物图标 | 操作按钮 | 动画效果 |
|------|------|------|-----------|----------|----------|----------|
| RoomHighValueGiftBannerAnimation | ✓ | ✓ | ✓ | ✓ | ✓ | SVGA |
| CPGiftBanner | ✓ | ✓✓(双人) | ✓ | ✓ | - | POP |
| BravoGiftBannerView | ✓ | ✓ | ✓ | ✓ | - | SVGA |
| LuckyPackageBannerView | ✓ | ✓ | ✓ | - | ✓ | POP |
| LuckyGiftWinningBannerView | ✓ | ✓ | ✓ | - | ✓ | POP |
| GameUniversalBannerView | ✓ | ✓ | ✓ | ✓ | ✓ | SVGA |
| PIUniversalBannerView | ✓ | - | ✓ | - | ✓ | SVGA |
### 共同数据模式
- AttachmentModel作为数据源
- 专用ViewModel进行数据解析
- 完成回调机制
- 用户交互跳转
## 架构设计
### 1. 基础父类设计
```objc
// YMBaseBannerView.h
#import <UIKit/UIKit.h>
#import "YMBannerDataProtocol.h"
#import "YMBannerDelegate.h"
NS_ASSUME_NONNULL_BEGIN
@class AttachmentModel, YMBaseBannerViewModel;
typedef NS_ENUM(NSUInteger, YMBannerType) {
YMBannerTypeHighValueGift, // 高价值礼物
YMBannerTypeCPGift, // CP礼物
YMBannerTypeBravoGift, // Bravo超级礼物
YMBannerTypeLuckyPackage, // 幸运红包
YMBannerTypeLuckyWinning, // 幸运中奖
YMBannerTypeGameUniversal, // 通用游戏
YMBannerTypeUniversal // 通用飘屏
};
typedef NS_ENUM(NSUInteger, YMBannerAnimationType) {
YMBannerAnimationTypeSlide, // 滑动动画
YMBannerAnimationTypeFade, // 淡入淡出
YMBannerAnimationTypeBounce, // 弹跳效果
YMBannerAnimationTypeCustom // 自定义动画
};
@interface YMBaseBannerView : UIView
#pragma mark - 核心属性
@property (nonatomic, assign, readonly) YMBannerType bannerType;
@property (nonatomic, strong) AttachmentModel *attachment;
@property (nonatomic, strong) YMBaseBannerViewModel *viewModel;
@property (nonatomic, weak) id<YMBannerDelegate> delegate;
#pragma mark - UI组件 (子类可选择使用)
@property (nonatomic, strong, readonly) UIView *containerView;
@property (nonatomic, strong, readonly) UIImageView *backgroundImageView;
@property (nonatomic, strong, readonly) NetImageView *avatarImageView;
@property (nonatomic, strong, readonly) NetImageView *secondAvatarImageView; // CP双头像
@property (nonatomic, strong, readonly) UILabel *titleLabel;
@property (nonatomic, strong, readonly) UILabel *subtitleLabel;
@property (nonatomic, strong, readonly) UILabel *contentLabel;
@property (nonatomic, strong, readonly) NetImageView *iconImageView;
@property (nonatomic, strong, readonly) UIButton *actionButton;
@property (nonatomic, strong, readonly) SVGAImageView *svgaView;
#pragma mark - 动画配置
@property (nonatomic, assign) YMBannerAnimationType animationType;
@property (nonatomic, assign) CGFloat showDuration;
@property (nonatomic, assign) CGFloat stayDuration;
@property (nonatomic, assign) CGFloat hideDuration;
@property (nonatomic, assign) BOOL enableSwipeGesture;
#pragma mark - 回调
@property (nonatomic, copy) void(^onDisplayComplete)(void);
@property (nonatomic, copy) void(^onUserTap)(YMBaseBannerView *banner);
@property (nonatomic, copy) void(^onActionTap)(YMBaseBannerView *banner);
@property (nonatomic, copy) void(^onDismiss)(YMBaseBannerView *banner);
#pragma mark - 状态
@property (nonatomic, assign, readonly) BOOL isDisplaying;
@property (nonatomic, assign, readonly) BOOL isDismissed;
#pragma mark - 类方法
+ (instancetype)bannerWithAttachment:(AttachmentModel *)attachment;
+ (void)displayInView:(UIView *)superView
attachment:(AttachmentModel *)attachment
complete:(void(^)(void))complete;
#pragma mark - 实例方法
- (instancetype)initWithBannerType:(YMBannerType)type;
- (void)configureWithAttachment:(AttachmentModel *)attachment;
- (void)displayInView:(UIView *)superView;
- (void)dismissWithAnimation:(BOOL)animated;
#pragma mark - 子类重写方法
- (Class)viewModelClass; // 返回对应的ViewModel类
- (void)setupUIComponents; // 设置UI组件
- (void)layoutUIComponents; // 布局UI组件
- (void)configureWithViewModel:(YMBaseBannerViewModel *)viewModel; // 配置数据
- (void)performShowAnimation; // 执行显示动画
- (void)performHideAnimation; // 执行隐藏动画
- (void)handleUserTap; // 处理用户点击
- (void)handleActionTap; // 处理操作按钮点击
#pragma mark - 用户反馈
- (void)reportDisplayEvent; // 上报展示事件
- (void)reportClickEvent:(NSString *)action; // 上报点击事件
- (void)reportDismissEvent:(NSString *)reason; // 上报消失事件
@end
NS_ASSUME_NONNULL_END
```
### 2. 基础数据模型
```objc
// YMBaseBannerViewModel.h
#import <Foundation/Foundation.h>
#import "PIBaseModel.h"
NS_ASSUME_NONNULL_BEGIN
@interface YMBaseBannerViewModel : PIBaseModel
#pragma mark - 基础信息
@property (nonatomic, assign) NSInteger roomUid;
@property (nonatomic, assign) NSInteger targetRoomUid;
@property (nonatomic, copy) NSString *title;
@property (nonatomic, copy) NSString *subtitle;
@property (nonatomic, copy) NSString *content;
#pragma mark - 用户信息
@property (nonatomic, copy) NSString *senderNick;
@property (nonatomic, copy) NSString *senderAvatar;
@property (nonatomic, assign) NSInteger senderUid;
@property (nonatomic, copy) NSString *receiverNick;
@property (nonatomic, copy) NSString *receiverAvatar;
@property (nonatomic, assign) NSInteger receiverUid;
#pragma mark - 视觉资源
@property (nonatomic, copy) NSString *backgroundImageUrl;
@property (nonatomic, copy) NSString *iconImageUrl;
@property (nonatomic, copy) NSString *svgaUrl;
#pragma mark - 交互配置
@property (nonatomic, copy) NSString *actionText;
@property (nonatomic, copy) NSString *skipUrl;
@property (nonatomic, assign) BOOL enableClick;
@property (nonatomic, assign) BOOL enableAction;
#pragma mark - 埋点数据
@property (nonatomic, copy) NSString *eventType;
@property (nonatomic, strong) NSDictionary *trackingData;
@end
NS_ASSUME_NONNULL_END
```
### 3. 代理协议设计
```objc
// YMBannerDelegate.h
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
@class YMBaseBannerView;
@protocol YMBannerDelegate <NSObject>
@optional
#pragma mark - 生命周期回调
- (void)bannerWillDisplay:(YMBaseBannerView *)banner;
- (void)bannerDidDisplay:(YMBaseBannerView *)banner;
- (void)bannerWillDismiss:(YMBaseBannerView *)banner;
- (void)bannerDidDismiss:(YMBaseBannerView *)banner;
#pragma mark - 交互回调
- (void)banner:(YMBaseBannerView *)banner didTapWithAction:(NSString *)action;
- (void)banner:(YMBaseBannerView *)banner willNavigateToRoom:(NSInteger)roomUid;
- (void)banner:(YMBaseBannerView *)banner willOpenURL:(NSString *)url;
#pragma mark - 数据回调
- (void)banner:(YMBaseBannerView *)banner didReportEvent:(NSString *)event data:(NSDictionary *)data;
@end
NS_ASSUME_NONNULL_END
```
## 子类设计
### 1. 高价值礼物Banner
```objc
// YMHighValueGiftBannerView.h
#import "YMBaseBannerView.h"
@interface YMHighValueGiftBannerView : YMBaseBannerView
@property (nonatomic, strong, readonly) UILabel *giftNameLabel;
@property (nonatomic, strong, readonly) UILabel *giftCountLabel;
@property (nonatomic, strong, readonly) MarqueeLabel *senderScrollLabel;
@property (nonatomic, strong, readonly) MarqueeLabel *roomNameScrollLabel;
@end
// YMHighValueGiftBannerViewModel.h
@interface YMHighValueGiftBannerViewModel : YMBaseBannerViewModel
@property (nonatomic, copy) NSString *giftName;
@property (nonatomic, assign) NSInteger giftCount;
@property (nonatomic, copy) NSString *giftImageUrl;
@property (nonatomic, assign) NSInteger bgLevel;
@property (nonatomic, copy) NSString *roomTitle;
@end
```
### 2. CP礼物Banner
```objc
// YMCPGiftBannerView.h
#import "YMBaseBannerView.h"
@interface YMCPGiftBannerView : YMBaseBannerView
@property (nonatomic, strong, readonly) UIStackView *cpStackView;
@property (nonatomic, strong, readonly) UILabel *relationLabel;
@end
// YMCPGiftBannerViewModel.h
@interface YMCPGiftBannerViewModel : YMBaseBannerViewModel
@property (nonatomic, copy) NSString *giftImageUrl;
@property (nonatomic, copy) NSString *relationText;
@end
```
### 3. 通用游戏Banner
```objc
// YMGameUniversalBannerView.h
#import "YMBaseBannerView.h"
@interface YMGameUniversalBannerView : YMBaseBannerView
@property (nonatomic, strong, readonly) NetImageView *gameIconView;
@property (nonatomic, assign) NSInteger gameID;
@end
// YMGameUniversalBannerViewModel.h
@interface YMGameUniversalBannerViewModel : YMBaseBannerViewModel
@property (nonatomic, copy) NSString *gameIconUrl;
@property (nonatomic, assign) NSInteger gameID;
@property (nonatomic, copy) NSString *gameTitle;
@end
```
## 用户反馈机制设计
### 1. 反馈管理器
```objc
// YMBannerFeedbackManager.h
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
typedef NS_ENUM(NSUInteger, YMBannerFeedbackType) {
YMBannerFeedbackTypeDisplay, // 展示
YMBannerFeedbackTypeClick, // 点击
YMBannerFeedbackTypeAction, // 操作
YMBannerFeedbackTypeDismiss, // 消失
YMBannerFeedbackTypeError // 错误
};
@interface YMBannerFeedbackManager : NSObject
+ (instancetype)shared;
#pragma mark - 事件上报
- (void)reportBannerEvent:(YMBannerFeedbackType)type
banner:(YMBaseBannerView *)banner
data:(NSDictionary *)data;
#pragma mark - 性能监控
- (void)startPerformanceMonitoring:(YMBaseBannerView *)banner;
- (void)endPerformanceMonitoring:(YMBaseBannerView *)banner;
#pragma mark - 错误收集
- (void)reportError:(NSError *)error
banner:(YMBaseBannerView *)banner
context:(NSDictionary *)context;
@end
NS_ASSUME_NONNULL_END
```
### 2. 埋点事件定义
```objc
// YMBannerTrackingEvents.h
extern NSString * const kYMBannerEventDisplay; // banner_display
extern NSString * const kYMBannerEventClick; // banner_click
extern NSString * const kYMBannerEventAction; // banner_action
extern NSString * const kYMBannerEventDismiss; // banner_dismiss
extern NSString * const kYMBannerEventLoadStart; // banner_load_start
extern NSString * const kYMBannerEventLoadEnd; // banner_load_end
extern NSString * const kYMBannerEventError; // banner_error
// 埋点参数Key
extern NSString * const kYMBannerTrackingKeyType; // banner_type
extern NSString * const kYMBannerTrackingKeyRoomUid; // room_uid
extern NSString * const kYMBannerTrackingKeyDuration; // duration
extern NSString * const kYMBannerTrackingKeyAction; // action
extern NSString * const kYMBannerTrackingKeyReason; // reason
```
## 上下文调整说明
### 1. RoomAnimationView.m 调整
#### 1.1 替换现有Banner创建逻辑
```objc
// 原代码
- (void)playRoomGiftBanner:(AttachmentModel *)obj {
[RoomHighValueGiftBannerAnimation display:self.bannerContainer
with:obj
complete:^{
self.isRoomBannerV2Displaying = NO;
[self processNextRoomEffectAttachment];
}];
}
// 新代码
- (void)playRoomGiftBanner:(AttachmentModel *)obj {
YMHighValueGiftBannerView *banner = [YMHighValueGiftBannerView bannerWithAttachment:obj];
banner.delegate = self;
banner.onDisplayComplete = ^{
self.isRoomBannerV2Displaying = NO;
[self processNextRoomEffectAttachment];
};
[banner displayInView:self.bannerContainer];
}
```
#### 1.2 添加代理实现
```objc
#pragma mark - YMBannerDelegate
- (void)banner:(YMBaseBannerView *)banner didReportEvent:(NSString *)event data:(NSDictionary *)data {
[[YMBannerFeedbackManager shared] reportBannerEvent:YMBannerFeedbackTypeDisplay
banner:banner
data:data];
}
- (void)banner:(YMBaseBannerView *)banner willNavigateToRoom:(NSInteger)roomUid {
// 处理房间跳转逻辑
RoomInfoModel *currentRoom = self.hostDelegate.getRoomInfo;
if (currentRoom.uid != roomUid) {
// 执行跨房间跳转
}
}
```
### 2. 项目依赖调整
#### 2.1 新增文件结构
```YuMi/Modules/YMRoom/View/Banner/
├── Base/
│ ├── YMBaseBannerView.h/.m
│ ├── YMBaseBannerViewModel.h/.m
│ └── YMBannerDelegate.h
├── Subclasses/
│ ├── YMHighValueGiftBannerView.h/.m
│ ├── YMCPGiftBannerView.h/.m
│ ├── YMBravoGiftBannerView.h/.m
│ ├── YMGameUniversalBannerView.h/.m
│ └── YMLuckyPackageBannerView.h/.m
├── Manager/
│ └── YMBannerFeedbackManager.h/.m
└── Constants/
└── YMBannerTrackingEvents.h/.m
```
#### 2.2 Podfile依赖更新
```ruby
# 确保动画库版本兼容
pod 'pop', '~> 1.0'
pod 'SVGAPlayer', '~> 2.3'
# 新增性能监控
pod 'YMPerformanceMonitor' # 如果有自定义性能监控库
```
### 3. 配置文件调整
#### 3.1 添加Banner配置
```objc
// YMBannerConfig.h
@interface YMBannerConfig : NSObject
@property (nonatomic, assign) BOOL enablePerformanceMonitoring;
@property (nonatomic, assign) BOOL enableErrorReporting;
@property (nonatomic, assign) CGFloat defaultShowDuration;
@property (nonatomic, assign) CGFloat defaultStayDuration;
@property (nonatomic, assign) CGFloat defaultHideDuration;
+ (instancetype)shared;
@end
```
### 4. 迁移策略
#### 4.1 Phase 1: 基础架构
- 创建基类和协议
- 实现反馈管理器
- 设置基础配置
#### 4.2 Phase 2: 渐进迁移
- 优先迁移使用频率最高的Banner
- 保持向后兼容性
- 添加单元测试
#### 4.3 Phase 3: 优化完善
- 移除旧代码
- 性能优化
- 文档完善
## 总结
这套新的Banner组件架构具备以下优势
1. **统一性**: 提供一致的API和行为模式
2. **扩展性**: 易于添加新的Banner类型
3. **可维护性**: 集中管理动画、埋点和反馈
4. **性能优化**: 统一的资源管理和内存优化
5. **用户体验**: 标准化的交互和反馈机制
通过这套架构可以显著提升Banner组件的开发效率和用户体验质量。

View File

@@ -1,80 +0,0 @@
# 飘屏组件分析文档
## 概述
本文档详细分析了项目中在 `RoomAnimationView.m``bannerContainer` 中展示的所有飘屏类型及其触发逻辑。
## 飘屏展示机制
- **展示容器**: `bannerContainer` (XPRoomAnimationHitView)
- **位置**: 视图层级最顶层,距离顶部导航栏高度
- **尺寸**: 屏幕宽度 × 180px 高度
- **管理机制**: 统一队列管理,按优先级顺序播放
## 飘屏类型详细表格
| 序号 | 飘屏名称 | 类名 | 触发消息类型 | 处理方法 | 业务场景 | 功能描述 |
|-----|---------|------|-------------|---------|----------|---------|
| 1 | 高价值礼物飘屏 | RoomHighValueGiftBannerAnimation | Custom_Message_Sub_Gift_ChannelNotify | playRoomGiftBanner: | 礼物系统 | 展示高价值礼物的全服广播 |
| 2 | CP礼物飘屏 | CPGiftBanner | Custom_Message_Sub_CP_Gift | playCPGiftBanner: | CP系统 | 展示CP相关礼物特效 |
| 3 | Bravo超级礼物飘屏 | BravoGiftBannerView | Custom_Message_Sub_Super_Gift_Banner | playBroveBanner: | 超级礼物 | 展示Bravo超级礼物效果 |
| 4 | 幸运红包飘屏 | LuckyPackageBannerView | Custom_Message_Sub_LuckyPackage | playLuckyPackageBanner: | 红包系统 | 展示房间红包,支持跨房间跳转 |
| 5 | 幸运礼物中奖飘屏 | LuckyGiftWinningBannerView | Custom_Message_Sub_Super_Gift_Winning_Coins_ALL_Room | playLuckyWinningBanner: | 中奖系统 | 展示全服中奖信息 |
| 6 | 通用游戏飘屏 | GameUniversalBannerView | Custom_Message_Sub_General_Floating_Screen_One_Room<br/>Custom_Message_Sub_General_Floating_Screen_All_Room | playGameBanner: | 游戏系统 | 展示游戏相关飘屏,支持跳转 |
| 7 | 塔罗飘屏 | XPRoomTarrowBannerView | Custom_Message_Sub_Tarot_Advanced<br/>Custom_Message_Sub_Tarot_Intermediate | createTarotBannerAnimation: | 塔罗活动 | 展示塔罗相关活动信息 |
| 8 | 星厨房飘屏 | XPRoomStarKitchenBannerView | Custom_Message_Sub_Star_Kitchen_FullScreen | createStarKitchenBannerAnimation: | 厨房活动 | 展示星厨房活动 |
| 9 | 夺宝精灵飘屏 | - | Custom_Message_Sub_Treasure_Fairy_Draw_Gift_L4<br/>Custom_Message_Sub_Treasure_Fairy_Draw_Gift_L5<br/>Custom_Message_Sub_Treasure_Fairy_Convert_L1/L2/L3 | createTreasureFairyBannerAnimation: | 夺宝活动 | 展示夺宝精灵高等级奖励 |
| 10 | 主播小时榜飘屏 | XPRoomAnchorRankBannerView | Custom_Message_Sub_Anchor_Hour_Rank | 创建主播排行榜飘屏 | 排行榜 | 展示主播小时榜信息 |
| 11 | 通用H5飘屏 | XPRoomTarrowBannerView | Custom_Message_Sub_Common_H5_Novice<br/>Custom_Message_Sub_Common_H5_Advanced | createCommonH5BannerAnimation: | H5活动 | 展示通用H5活动飘屏 |
| 12 | 通用飘屏 | PIUniversalBannerView | 多种消息类型 | createGeneralFloatingScreenAnimation: | 通用展示 | 提供通用飘屏展示能力 |
## 队列管理机制
### 核心属性
- **队列数组**: `roomBannertModelsQueueV2` (NSMutableArray)
- **状态标记**: `isRoomBannerV2Displaying` (BOOL)
### 处理流程
1. **消息接收**: 调用 `inserBannerModelToQueue:` 将消息加入队列
2. **优先级排序**: 调用 `sortBannerQueue` 按消息类型的second值排序数值越小优先级越高
3. **顺序播放**: 调用 `processNextRoomEffectAttachment` 逐个播放
4. **播放完成**: 每个飘屏播放完成后,设置 `isRoomBannerV2Displaying = NO` 并处理下一个
### 优先级规则
飘屏按 `AttachmentModel.second` 值进行排序,确保重要消息优先展示。
## 消息类型分类
### 礼物相关
- Custom_Message_Sub_Gift_ChannelNotify (32)
- Custom_Message_Sub_CP_Gift
- Custom_Message_Sub_Super_Gift_Banner (1066)
### 红包/福袋相关
- Custom_Message_Sub_LuckyPackage (607)
- Custom_Message_Sub_Super_Gift_Winning_Coins_ALL_Room (1063)
### 游戏相关
- Custom_Message_Sub_General_Floating_Screen_One_Room (1071)
- Custom_Message_Sub_General_Floating_Screen_All_Room (1072)
### 活动相关
- Custom_Message_Sub_Tarot_Advanced (714)
- Custom_Message_Sub_Tarot_Intermediate (713)
- Custom_Message_Sub_Star_Kitchen_FullScreen (1042)
- Custom_Message_Sub_Treasure_Fairy_* (9700系列)
### 排行榜相关
- Custom_Message_Sub_Anchor_Hour_Rank (891)
### 通用相关
- Custom_Message_Sub_Common_H5_* (1100系列)

View File

@@ -1,203 +0,0 @@
# 飘屏组件抽象分析
## 分析概述
基于对项目中12种飘屏组件的深入分析发现这些组件存在高度相似的设计模式和实现结构具备抽象出统一父类的条件。
## 现状分析
### 共同特征
经过代码分析,发现所有飘屏组件都具有以下共同特征:
#### 1. 统一的类方法签名模式
大部分Banner类都采用了相似的类方法签名
``` objc
+ (void)display:(UIView *)superView
with:(AttachmentModel *)attachment
complete:(void(^)(void))complete;
// 或者带房间信息的版本
+ (void)display:(UIView *)superView
inRoomUid:(NSInteger)roomUid
with:(AttachmentModel *)attachment
complete:(void(^)(void))complete
exitCurrentRoom:(void(^)(void))exit;
```
#### 2. 共同的内部属性结构
```objc
@property (nonatomic, strong) SomeViewModel *model; // 数据模型
@property (nonatomic, strong) UIImageView *backgroundImageView; // 背景图片
@property (nonatomic, copy) void(^completeDisplay)(void); // 完成回调
@property (nonatomic, copy) void(^exitCurrentRoom)(void); // 退房回调
@property (nonatomic, assign) NSInteger currentRoomUid; // 当前房间ID
```
#### 3. 相似的动画流程
- 从屏幕右侧滑入 (x = KScreenWidth)
- 短暂停留展示
- 滑出屏幕或淡出
- 执行完成回调
#### 4. 统一的数据处理模式
- 都依赖 `AttachmentModel` 作为数据源
- 都创建对应的ViewModel进行数据解析
- 都继承自 `PIBaseModel`
#### 5. 相似的UI组件
- 背景图片视图
- 用户头像
- 文本标签
- 可选的SVGA动画视图
- 可选的交互按钮
## 抽象方案设计
### 建议的基类结构: `BaseRoomBannerView`
```objc
@interface BaseRoomBannerView : UIView
#pragma mark - 核心属性
@property (nonatomic, strong) AttachmentModel *attachment;
@property (nonatomic, strong) PIBaseModel *viewModel;
@property (nonatomic, assign) NSInteger currentRoomUid;
@property (nonatomic, assign) NSInteger targetRoomUid;
#pragma mark - UI组件
@property (nonatomic, strong) UIView *containerView;
@property (nonatomic, strong) UIImageView *backgroundImageView;
@property (nonatomic, strong) NetImageView *avatarImageView;
@property (nonatomic, strong) UILabel *titleLabel;
@property (nonatomic, strong) UILabel *subTitleLabel;
@property (nonatomic, strong) UIButton *actionButton;
@property (nonatomic, strong) SVGAImageView *svgaView;
#pragma mark - 动画配置
@property (nonatomic, assign) CGFloat showDuration;
@property (nonatomic, assign) CGFloat stayDuration;
@property (nonatomic, assign) CGFloat hideDuration;
@property (nonatomic, assign) BOOL useSlideAnimation;
@property (nonatomic, assign) BOOL useFadeAnimation;
#pragma mark - 回调
@property (nonatomic, copy) void(^completeDisplay)(void);
@property (nonatomic, copy) void(^exitCurrentRoom)(void);
@property (nonatomic, copy) void(^didTapBanner)(NSInteger roomID);
#pragma mark - 类方法
+ (instancetype)createWithAttachment:(AttachmentModel *)attachment
inRoomUid:(NSInteger)roomUid
complete:(void(^)(void))complete
exitCurrentRoom:(void(^)(void))exit;
+ (void)display:(UIView *)superView
with:(AttachmentModel *)attachment
complete:(void(^)(void))complete;
+ (void)display:(UIView *)superView
inRoomUid:(NSInteger)roomUid
with:(AttachmentModel *)attachment
complete:(void(^)(void))complete
exitCurrentRoom:(void(^)(void))exit;
#pragma mark - 实例方法
- (void)setupWithAttachment:(AttachmentModel *)attachment;
- (void)showInSuperView:(UIView *)superView;
- (void)performShowAnimation;
- (void)performHideAnimation;
#pragma mark - 子类重写方法
- (Class)viewModelClass; // 返回对应的ViewModel类
- (void)setupUI; // 设置UI布局
- (void)configureWithModel:(PIBaseModel *)model; // 配置数据
- (void)customizeAnimation; // 自定义动画
- (void)handleTapAction; // 处理点击事件
@end
```
### 抽象优势分析
#### 1. 代码复用率提升
- 消除重复的动画逻辑
- 统一的数据处理流程
- 共享的UI组件管理
#### 2. 维护成本降低
- 集中管理动画参数
- 统一的回调处理机制
- 标准化的生命周期管理
#### 3. 扩展性增强
- 新增飘屏类型只需继承基类
- 便于添加通用功能(如埋点、性能监控)
- 支持主题切换等全局功能
#### 4. 一致性保证
- 统一的动画效果和时长
- 标准化的交互行为
- 一致的视觉表现
### 实现策略
#### 阶段一:创建基类
1. 提取共同属性和方法
2. 实现通用动画逻辑
3. 定义子类接口规范
#### 阶段二:重构现有类
1. 逐步迁移现有Banner类继承基类
2. 移除重复代码
3. 保持向后兼容
#### 阶段三:优化和扩展
1. 添加性能监控
2. 实现主题支持
3. 统一埋点逻辑
### 潜在挑战
#### 1. 兼容性问题
- 现有代码的重构风险
- 不同Banner的特殊需求差异
- 第三方依赖的适配
#### 2. 性能考虑
- 基类功能过于复杂可能影响性能
- 内存占用的优化
- 动画性能的平衡
#### 3. 维护复杂度
- 基类变更可能影响所有子类
- 需要详细的文档和测试
- 团队学习成本
## 结论
**建议进行抽象**:项目中的飘屏组件具有高度的相似性,抽象出基类能够显著提升代码质量和开发效率。建议采用渐进式重构策略,先创建基类和接口规范,再逐步迁移现有实现,确保系统稳定性的同时实现架构优化。
## 优先级建议
1. **高优先级**:创建 `BaseRoomBannerView` 基类
2. **中优先级**重构使用频率最高的Banner类
3. **低优先级**:添加扩展功能和优化
通过这种抽象设计,能够在保持现有功能完整性的同时,为未来的功能扩展和维护奠定良好的架构基础。

View File

@@ -531,6 +531,8 @@
4C7F2A672E0BE0AB002F5058 /* FirstRechargeModel.m in Sources */ = {isa = PBXBuildFile; fileRef = 4C7F2A662E0BE0AB002F5058 /* FirstRechargeModel.m */; };
4C7F2A6B2E0BE7E7002F5058 /* FirstRechargeManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 4C7F2A6A2E0BE7E7002F5058 /* FirstRechargeManager.m */; };
4C815A172CFEB758002A46A6 /* SuperBlockViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 4C815A162CFEB758002A46A6 /* SuperBlockViewController.m */; };
4C84A9C22E5ED593002C10FC /* GameBannerGestureManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 4C84A9C12E5ED593002C10FC /* GameBannerGestureManager.m */; };
4C84A9CB2E602B1A002C10FC /* BuglyManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 4C84A9CA2E602B1A002C10FC /* BuglyManager.m */; };
4C85DB812DCDD83E00FD9839 /* CreateEventPresenter.m in Sources */ = {isa = PBXBuildFile; fileRef = 4C85DB802DCDD83E00FD9839 /* CreateEventPresenter.m */; };
4C85DB842DCDDD6800FD9839 /* CreateEventViewControllerV2.m in Sources */ = {isa = PBXBuildFile; fileRef = 4C85DB832DCDDD6800FD9839 /* CreateEventViewControllerV2.m */; };
4C864A022D55F4F600191AE0 /* LuckyPackagePresenter.m in Sources */ = {isa = PBXBuildFile; fileRef = 4C864A012D55F4F600191AE0 /* LuckyPackagePresenter.m */; };
@@ -572,6 +574,7 @@
4CD15D912D7E902800D9279F /* LoginViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 4CD15D902D7E902800D9279F /* LoginViewController.m */; };
4CD15D922D7EC2AC00D9279F /* CoreTelephony.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 23E56B3B2B03564B00C8DAC9 /* CoreTelephony.framework */; };
4CD15D952D7FE9E400D9279F /* LoginTypesViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 4CD15D942D7FE9E400D9279F /* LoginTypesViewController.m */; };
4CD47BB52E61514900BCDA46 /* StageViewManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 4CD47BB42E61514900BCDA46 /* StageViewManager.m */; };
4CD6FF662D673A5C00262AB7 /* AgentMessageModel.m in Sources */ = {isa = PBXBuildFile; fileRef = 4CD6FF652D673A5C00262AB7 /* AgentMessageModel.m */; };
4CD6FF692D673F7F00262AB7 /* AgentMessageTableViewCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 4CD6FF682D673F7F00262AB7 /* AgentMessageTableViewCell.m */; };
4CE3A9462D22754C003F0796 /* RechargeUserModel.m in Sources */ = {isa = PBXBuildFile; fileRef = 4CE3A9452D22754C003F0796 /* RechargeUserModel.m */; };
@@ -2725,6 +2728,10 @@
4C7F2A6A2E0BE7E7002F5058 /* FirstRechargeManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FirstRechargeManager.m; sourceTree = "<group>"; };
4C815A152CFEB758002A46A6 /* SuperBlockViewController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SuperBlockViewController.h; sourceTree = "<group>"; };
4C815A162CFEB758002A46A6 /* SuperBlockViewController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SuperBlockViewController.m; sourceTree = "<group>"; };
4C84A9C02E5ED593002C10FC /* GameBannerGestureManager.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GameBannerGestureManager.h; sourceTree = "<group>"; };
4C84A9C12E5ED593002C10FC /* GameBannerGestureManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = GameBannerGestureManager.m; sourceTree = "<group>"; };
4C84A9C92E602B1A002C10FC /* BuglyManager.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = BuglyManager.h; sourceTree = "<group>"; };
4C84A9CA2E602B1A002C10FC /* BuglyManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = BuglyManager.m; sourceTree = "<group>"; };
4C85DB7F2DCDD83E00FD9839 /* CreateEventPresenter.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CreateEventPresenter.h; sourceTree = "<group>"; };
4C85DB802DCDD83E00FD9839 /* CreateEventPresenter.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CreateEventPresenter.m; sourceTree = "<group>"; };
4C85DB822DCDDD6800FD9839 /* CreateEventViewControllerV2.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CreateEventViewControllerV2.h; sourceTree = "<group>"; };
@@ -2803,6 +2810,8 @@
4CD15D902D7E902800D9279F /* LoginViewController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = LoginViewController.m; sourceTree = "<group>"; };
4CD15D932D7FE9E400D9279F /* LoginTypesViewController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = LoginTypesViewController.h; sourceTree = "<group>"; };
4CD15D942D7FE9E400D9279F /* LoginTypesViewController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = LoginTypesViewController.m; sourceTree = "<group>"; };
4CD47BB32E61514900BCDA46 /* StageViewManager.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = StageViewManager.h; sourceTree = "<group>"; };
4CD47BB42E61514900BCDA46 /* StageViewManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = StageViewManager.m; sourceTree = "<group>"; };
4CD6FF642D673A5C00262AB7 /* AgentMessageModel.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AgentMessageModel.h; sourceTree = "<group>"; };
4CD6FF652D673A5C00262AB7 /* AgentMessageModel.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AgentMessageModel.m; sourceTree = "<group>"; };
4CD6FF672D673F7F00262AB7 /* AgentMessageTableViewCell.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AgentMessageTableViewCell.h; sourceTree = "<group>"; };
@@ -6931,6 +6940,8 @@
children = (
4CFE7F3F2E45ECEC00F77776 /* PublicRoomManager.h */,
4CFE7F402E45ECEC00F77776 /* PublicRoomManager.m */,
4C84A9C02E5ED593002C10FC /* GameBannerGestureManager.h */,
4C84A9C12E5ED593002C10FC /* GameBannerGestureManager.m */,
);
path = Manager;
sourceTree = "<group>";
@@ -8277,6 +8288,8 @@
E81C279926EB64BA0031E639 /* Global */ = {
isa = PBXGroup;
children = (
4C84A9C92E602B1A002C10FC /* BuglyManager.h */,
4C84A9CA2E602B1A002C10FC /* BuglyManager.m */,
E81C279A26EB65560031E639 /* YUMIMacroUitls.h */,
E81C279B26EEEC620031E639 /* YUMIConstant.h */,
E81C279C26EEEC620031E639 /* YUMIConstant.m */,
@@ -10783,6 +10796,8 @@
E8AEAED8271413530017FCE0 /* View */ = {
isa = PBXGroup;
children = (
4CD47BB32E61514900BCDA46 /* StageViewManager.h */,
4CD47BB42E61514900BCDA46 /* StageViewManager.m */,
4CE746C92D929D500094E496 /* Common */,
4CB753CF2D30F08E00B13DF5 /* LuckyPackage */,
4CB753CE2D2FE80100B13DF5 /* RoomSideMenu */,
@@ -12094,6 +12109,7 @@
E821077B2987D4AB00DE7040 /* MessageFindNewGreetModel.m in Sources */,
4C886BF22E015D61006F0BA7 /* MedalsModel.m in Sources */,
E85E7BA32A4EC99300B6D00A /* XPMineGiveDiamondDetailsVC.m in Sources */,
4CD47BB52E61514900BCDA46 /* StageViewManager.m in Sources */,
238A90072BA9729200828123 /* PIUniversalBannerView.m in Sources */,
E8B846D826FDE17300A777FE /* XPMineRechargeProtocol.h in Sources */,
E88C72A6282921D60047FB2B /* XPRoomBackMusicPlayerView.m in Sources */,
@@ -12132,6 +12148,7 @@
E87C54BE2823CC5B0051AA11 /* XPMineResetLoginPwdPresenter.m in Sources */,
237852A42C082A9800E360AC /* MSRoomGameSendTextView.m in Sources */,
E85E7B322A4EB0D300B6D00A /* XPGuildAnchorIncomeSectionView.m in Sources */,
4C84A9CB2E602B1A002C10FC /* BuglyManager.m in Sources */,
E87C0AA027D9DE6400CB2241 /* RoomFaceSendInfoModel.m in Sources */,
1464C5F629A4CA8C00AF7C94 /* XPIAPRechargeCollectionViewCell.m in Sources */,
E8751E6328A646400056EF44 /* XPSailingRankView.m in Sources */,
@@ -12153,6 +12170,7 @@
E85E7B242A4EB0D300B6D00A /* XPMineGuildSuperAdminSetViewController.m in Sources */,
E87DF4C22A42C900009C1185 /* XPNoteView.m in Sources */,
E8AB631328ADDCF20023B0D2 /* XPMonentsTopicHeaderView.m in Sources */,
4C84A9C22E5ED593002C10FC /* GameBannerGestureManager.m in Sources */,
238B37B52AC55A2C00BFC9D5 /* XPTreasureFailyResultGiftCell.m in Sources */,
23E9E9982A80C3A100B792F2 /* XPMineGuildPersonalBillStatisVC.m in Sources */,
9B86D886281942D200494FCD /* SocialMicroView.m in Sources */,
@@ -13692,7 +13710,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 20.20.62;
MARKETING_VERSION = 20.20.63;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
@@ -13939,7 +13957,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 20.20.62;
MARKETING_VERSION = 20.20.63;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",

View File

@@ -27,6 +27,7 @@
#import <UserNotifications/UserNotifications.h>
#import <Bugly/Bugly.h>
#import "BuglyManager.h"
#import <UIKit/UIDevice.h>
#import "YuMi-swift.h"
@@ -78,23 +79,11 @@ UIKIT_EXTERN NSString * adImageName;
*/
- (void) configBugly {
BuglyConfig *config = [[BuglyConfig alloc] init];
config.blockMonitorTimeout = 5;
// 使 BuglyManager Bugly
#ifdef DEBUG
config.debugMode = NO;//YES; // debug
config.channel = [YYUtility getAppSource];
config.reportLogLevel = BuglyLogLevelWarn;// BuglyLogLevelSilent; // BuglyLogLevelVerbose; //
[Bugly startWithAppId:@"c937fd00f7" config:config];
[[BuglyManager sharedManager] configureWithAppId:@"c937fd00f7" debug:YES];
#else
config.unexpectedTerminatingDetectionEnable = YES; // 退
config.debugMode = NO; // release
config.channel = [YYUtility getAppSource];;
config.blockMonitorEnable = YES; //
config.reportLogLevel = BuglyLogLevelWarn; //
NSString *buylyKey = @"8627948559";
[Bugly startWithAppId:buylyKey config:config];
[[BuglyManager sharedManager] configureWithAppId:@"8627948559" debug:NO];
#endif
}
- (void)configNIMSDK {
@@ -161,23 +150,37 @@ UIKIT_EXTERN NSString * adImageName;
NSMutableArray * array = [NSMutableArray array];
for (int i = 0; i < emojiArray.count; i++) {
UIImage * image = [UIImage imageNamed:dic[@"file"]];
NSDictionary * emotionDic = [emojiArray xpSafeObjectAtIndex:i];
if (!emotionDic) continue;
UIImage * image = [UIImage imageNamed:emotionDic[@"file"]];
QEmotion * info = [[QEmotion alloc] init];
NSDictionary * dic = [emojiArray xpSafeObjectAtIndex:i];
if (dic) {
info.displayName = dic[@"tag"];
info.identifier = dic[@"id"];
}
info.displayName = emotionDic[@"tag"];
info.identifier = emotionDic[@"id"];
info.image = image;
//
NSLog(@"加载表情: %@, 图片: %@, 是否成功: %@", info.displayName, emotionDic[@"file"], image ? @"是" : @"否");
[array addObject:info];
}
//
QEmotionHelper *faceManager = [QEmotionHelper sharedEmotionHelper];
faceManager.emotionArray = array;
// emoji
[QEmotionHelper clearEmojiCache];
#if DEBUG
//
NSLog(@"表情数组加载完成,总数: %lu", (unsigned long)array.count);
for (int i = 0; i < MIN(array.count, 3); i++) {
QEmotion *emotion = array[i];
NSLog(@"测试表情 %d: %@, 图片: %@", i, emotion.displayName, emotion.image ? @"加载成功" : @"加载失败");
}
});
#endif
}
#pragma mark - 广

View File

@@ -128,3 +128,4 @@
<Emoticon ID="emoticon_emoji_115" Tag="[自行车]" File="emoji_115.png" />
</Catalog>
</PopoEmoticons>
˜

View File

@@ -111,6 +111,10 @@ const int UISendButtonHeight = 41;
if (isSizeChanged) {
[self setNeedsLayoutEmotions];
}
//
NSLog(@"UIEmotionPageView layoutSubviews: frame=%@, needsLayoutEmotions=%d", NSStringFromCGRect(self.frame), self.needsLayoutEmotions);
[self layoutEmotionsIfNeeded];
}
@@ -120,6 +124,14 @@ const int UISendButtonHeight = 41;
- (void)setEmotions:(NSArray<QEmotion *> *)emotions {
if ([_emotions isEqualToArray:emotions]) return;
//
NSLog(@"UIEmotionPageView setEmotions: 表情数量=%lu", (unsigned long)emotions.count);
if (emotions.count > 0) {
QEmotion *firstEmotion = emotions[0];
NSLog(@"UIEmotionPageView 第一个表情: %@, 图片: %@", firstEmotion.displayName, firstEmotion.image ? @"存在" : @"不存在");
}
_emotions = emotions;
[self setNeedsLayoutEmotions];
[self setNeedsLayout];
@@ -156,6 +168,13 @@ const int UISendButtonHeight = 41;
}
emotionlayer.contents = (__bridge id)(self.emotions[i].image.CGImage);//使layer
//
if (i < 3) { // 3
NSLog(@"渲染表情 %ld: %@, 图片: %@, CGImage: %@", (long)i, self.emotions[i].displayName,
self.emotions[i].image ? @"存在" : @"不存在",
self.emotions[i].image.CGImage ? @"存在" : @"不存在");
}
NSInteger row = i / emotionCountPerRow;
emotionOrigin.x = self.padding.left + (self.emotionSize.width + emotionHorizontalSpacing) * (i % emotionCountPerRow);
emotionOrigin.y = self.padding.top + (self.emotionSize.height + emotionVerticalSpacing) * row;
@@ -246,12 +265,29 @@ const int UISendButtonHeight = 41;
emotionVerticalSpacing:(CGFloat)emotionVerticalSpacing
emotionSelectedBackgroundExtension:(UIEdgeInsets)emotionSelectedBackgroundExtension
paddingInPage:(UIEdgeInsets)paddingInPage {
//
NSLog(@"UIEmotionVerticalScrollView setEmotions: 表情数量=%lu, emotionSize=%@", (unsigned long)emotions.count, NSStringFromCGSize(emotionSize));
if (emotions.count > 0) {
QEmotion *firstEmotion = emotions[0];
NSLog(@"第一个表情: %@, 图片: %@", firstEmotion.displayName, firstEmotion.image ? @"存在" : @"不存在");
}
UIEmotionPageView *pageView = self.pageView;
pageView.emotions = emotions;
pageView.padding = paddingInPage;
//
NSLog(@"UIEmotionVerticalScrollView bounds: %@", NSStringFromCGRect(self.bounds));
NSLog(@"UIEmotionVerticalScrollView paddingInPage: %@", NSStringFromUIEdgeInsets(paddingInPage));
CGSize contentSize = CGSizeMake(self.bounds.size.width - [self edgeInsetsGetHorizontalValue:paddingInPage], self.bounds.size.height - [self edgeInsetsGetVerticalValue:paddingInPage]);
NSLog(@"UIEmotionVerticalScrollView contentSize: %@", NSStringFromCGSize(contentSize));
NSInteger emotionCountPerRow = (contentSize.width + minimumEmotionHorizontalSpacing) / (emotionSize.width + minimumEmotionHorizontalSpacing);
pageView.numberOfRows = ceil(emotions.count / (CGFloat)emotionCountPerRow);
NSLog(@"UIEmotionVerticalScrollView emotionCountPerRow: %ld, numberOfRows: %ld", (long)emotionCountPerRow, (long)pageView.numberOfRows);
pageView.emotionSize =emotionSize;
pageView.emotionSelectedBackgroundExtension = emotionSelectedBackgroundExtension;
pageView.minimumEmotionHorizontalSpacing = minimumEmotionHorizontalSpacing;
@@ -370,6 +406,14 @@ const int UISendButtonHeight = 41;
- (void)setEmotions:(NSArray<QEmotion *> *)emotions {
_emotions = emotions;
//
NSLog(@"QEmotionBoardView 设置表情数组,数量: %lu", (unsigned long)emotions.count);
for (int i = 0; i < MIN(emotions.count, 5); i++) {
QEmotion *emotion = emotions[i];
NSLog(@"表情 %d: %@, 图片: %@", i, emotion.displayName, emotion.image ? @"存在" : @"不存在");
}
[self setNeedsLayout];
}

View File

@@ -25,7 +25,17 @@
//把 @"[微笑]" 转为 @"😊"
- (NSAttributedString *)obtainAttributedStringByImageKey:(NSString *)imageKey font:(UIFont *)font useCache:(BOOL)useCache;
//imageKey[微笑] fontlabel的Font返回😊 (带场景参数)
//把 @"[微笑]" 转为 @"😊"
- (NSAttributedString *)obtainAttributedStringByImageKey:(NSString *)imageKey font:(UIFont *)font useCache:(BOOL)useCache forMessageBubble:(BOOL)isMessageBubble;
//把 @"害~你好啊[微笑]" 转为 @"害~你好啊😊"
- (NSMutableAttributedString *)attributedStringByText:(NSString *)text font:(UIFont *)font;
//把 @"害~你好啊[微笑]" 转为 @"害~你好啊😊" (带场景参数)
- (NSMutableAttributedString *)attributedStringByText:(NSString *)text font:(UIFont *)font forMessageBubble:(BOOL)isMessageBubble;
// 清理 emoji 缓存,用于尺寸变更后刷新
+ (void)clearEmojiCache;
@end

View File

@@ -46,6 +46,12 @@
return _sharedFaceManager;
}
// emoji
+ (void)clearEmojiCache {
QEmotionHelper *helper = [QEmotionHelper sharedEmotionHelper];
[helper.cacheAttributedDictionary removeAllObjects];
}
#pragma mark - public
//demo
//emotionArrayImage0.5MB
@@ -78,6 +84,11 @@
//String@"害~你好[微笑]", @"害~你好😊"
- (NSMutableAttributedString *)attributedStringByText:(NSString *)text font:(UIFont *)font {
return [self attributedStringByText:text font:font forMessageBubble:NO];
}
//String@"害~你好[微笑]", @"害~你好😊" ()
- (NSMutableAttributedString *)attributedStringByText:(NSString *)text font:(UIFont *)font forMessageBubble:(BOOL)isMessageBubble {
if(text.length == 0){
return [[NSMutableAttributedString alloc] initWithString:@""];;
}
@@ -98,7 +109,7 @@
//ios15NSTextAttachmentViewProvider
useCache = NO;
}
NSAttributedString *imageAttributedString = [self obtainAttributedStringByImageKey:emojiKey font:font useCache:useCache];
NSAttributedString *imageAttributedString = [self obtainAttributedStringByImageKey:emojiKey font:font useCache:useCache forMessageBubble:isMessageBubble];
if (imageAttributedString) {
[intactAttributeString replaceCharactersInRange:result.range withAttributedString:imageAttributedString];
}
@@ -115,6 +126,13 @@
//imageKey[] fontlabelFont😊
// @@"[微笑]", @"😊"
- (NSAttributedString *)obtainAttributedStringByImageKey:(NSString *)imageKey font:(UIFont *)font useCache:(BOOL)useCache {
return [self obtainAttributedStringByImageKey:imageKey font:font useCache:useCache forMessageBubble:NO];
}
//AttributedString ()
//imageKey[] fontlabelFont😊
// @@"[微笑]", @"😊"
- (NSAttributedString *)obtainAttributedStringByImageKey:(NSString *)imageKey font:(UIFont *)font useCache:(BOOL)useCache forMessageBubble:(BOOL)isMessageBubble {
if([self.emojiHantList containsObject:imageKey]){
NSString *getImageKey = [self.emojiHansList xpSafeObjectAtIndex:[self.emojiHantList indexOfObject:imageKey]];
if(getImageKey != nil){
@@ -133,14 +151,17 @@
imageView.contentMode = UIViewContentModeScaleAspectFit;
imageView.displayText = imageKey;
imageView.image = image;
// emoji
CGFloat emojiSize = isMessageBubble ? font.lineHeight * 2.0 : font.lineHeight; //
if (image) {
CGFloat scale = image.size.width / image.size.height;
imageView.bounds = CGRectMake(0, 0, 20 * scale, 20);
imageView.bounds = CGRectMake(0, 0, emojiSize * scale, emojiSize);
} else {
imageView.bounds = CGRectMake(0, 0, 20, 20);
imageView.bounds = CGRectMake(0, 0, emojiSize, emojiSize);
}
imageView.bounds = CGRectMake(0, 0, font.lineHeight, font.lineHeight);
NSMutableAttributedString * attrString = [NSMutableAttributedString yy_attachmentStringWithContent:imageView contentMode:UIViewContentModeScaleAspectFit attachmentSize:CGSizeMake(imageView.bounds.size.width, imageView.bounds.size.height) alignToFont:[UIFont systemFontOfSize:15.0] alignment:YYTextVerticalAlignmentCenter];
NSMutableAttributedString * attrString = [NSMutableAttributedString yy_attachmentStringWithContent:imageView contentMode:UIViewContentModeScaleAspectFit attachmentSize:CGSizeMake(imageView.bounds.size.width, imageView.bounds.size.height) alignToFont:font alignment:YYTextVerticalAlignmentCenter];
return attrString;
}
@@ -165,14 +186,17 @@
imageView.contentMode = UIViewContentModeScaleAspectFit;
imageView.displayText = imageKey;
imageView.image = image;
// emoji
CGFloat emojiSize = isMessageBubble ? font.lineHeight * 2.0 : font.lineHeight; //
if (image) {
CGFloat scale = image.size.width / image.size.height;
imageView.bounds = CGRectMake(0, 0, 20 * scale, 20);
imageView.bounds = CGRectMake(0, 0, emojiSize * scale, emojiSize);
} else {
imageView.bounds = CGRectMake(0, 0, 20, 20);
imageView.bounds = CGRectMake(0, 0, emojiSize, emojiSize);
}
imageView.bounds = CGRectMake(0, 0, font.lineHeight, font.lineHeight);
NSMutableAttributedString * attrString = [NSMutableAttributedString yy_attachmentStringWithContent:imageView contentMode:UIViewContentModeScaleAspectFit attachmentSize:CGSizeMake(imageView.bounds.size.width, imageView.bounds.size.height) alignToFont:[UIFont systemFontOfSize:15.0] alignment:YYTextVerticalAlignmentCenter];
NSMutableAttributedString * attrString = [NSMutableAttributedString yy_attachmentStringWithContent:imageView contentMode:UIViewContentModeScaleAspectFit attachmentSize:CGSizeMake(imageView.bounds.size.width, imageView.bounds.size.height) alignToFont:font alignment:YYTextVerticalAlignmentCenter];
//[]17 NSAttributedString Dictionary
[_cacheAttributedDictionary setObject:attrString forKey:keyFont];
return result;

115
YuMi/Global/BuglyManager.h Normal file
View File

@@ -0,0 +1,115 @@
//
// BuglyManager.h
// YuMi
//
// Created by BuglyManager
// Copyright © 2024 YuMi. All rights reserved.
//
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
@class BuglyManager;
/**
* BuglyManager 代理协议
* 用于监听卡顿和性能问题
*/
@protocol BuglyManagerDelegate <NSObject>
@optional
/**
* 检测到卡顿时的回调
* @param manager BuglyManager 实例
* @param duration 卡顿持续时间(秒)
*/
- (void)buglyManager:(BuglyManager *)manager didDetectLag:(NSTimeInterval)duration;
/**
* 检测到主线程阻塞时的回调
* @param manager BuglyManager 实例
* @param duration 阻塞持续时间(秒)
*/
- (void)buglyManager:(BuglyManager *)manager didDetectBlock:(NSTimeInterval)duration;
@end
/**
* Bugly 统一管理类
* 封装所有 Bugly 相关操作,提供统一的错误上报和性能监控接口
*/
@interface BuglyManager : NSObject
/**
* 单例访问方法
* @return BuglyManager 单例实例
*/
+ (instancetype)sharedManager;
/**
* 设置代理对象
*/
@property (nonatomic, assign) id<BuglyManagerDelegate> delegate;
/**
* 配置并启动 Bugly
* @param appId Bugly 应用 ID
* @param isDebug 是否为调试模式
*/
- (void)configureWithAppId:(NSString *)appId debug:(BOOL)isDebug;
/**
* 上报错误信息
* @param domain 错误域
* @param code 错误码
* @param userInfo 错误详细信息
*/
- (void)reportError:(NSString *)domain
code:(NSInteger)code
userInfo:(NSDictionary *)userInfo;
/**
* 上报业务错误(简化版)
* @param message 错误消息
* @param code 错误码
* @param context 错误上下文信息
*/
- (void)reportBusinessError:(NSString *)message
code:(NSInteger)code
context:(NSDictionary *)context;
/**
* 上报网络请求异常
* @param uid 用户ID
* @param api 接口路径
* @param code 错误码
* @param userInfo 额外信息
*/
- (void)reportNetworkError:(NSString *)uid
api:(NSString *)api
code:(NSInteger)code
userInfo:(NSDictionary *)userInfo;
/**
* 上报内购相关错误
* @param uid 用户ID
* @param transactionId 交易ID
* @param orderId 订单ID
* @param status 状态码
* @param context 上下文信息
*/
- (void)reportIAPError:(NSString *)uid
transactionId:(NSString *)transactionId
orderId:(NSString *)orderId
status:(NSInteger)status
context:(NSDictionary *)context;
/**
* 手动触发卡顿检测
*/
- (void)startLagDetection;
@end
NS_ASSUME_NONNULL_END

262
YuMi/Global/BuglyManager.m Normal file
View File

@@ -0,0 +1,262 @@
//
// BuglyManager.m
// YuMi
//
// Created by BuglyManager
// Copyright © 2024 YuMi. All rights reserved.
//
#import "BuglyManager.h"
#import <Bugly/Bugly.h>
@interface BuglyManager () <BuglyDelegate>
@property (nonatomic, strong) NSString *appId;
@property (nonatomic, assign) BOOL isConfigured;
@end
@implementation BuglyManager
#pragma mark - Singleton
+ (instancetype)sharedManager {
static BuglyManager *instance = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
instance = [[BuglyManager alloc] init];
});
return instance;
}
- (instancetype)init {
self = [super init];
if (self) {
_isConfigured = NO;
}
return self;
}
#pragma mark - BuglyDelegate
- (NSString * BLY_NULLABLE)attachmentForException:(NSException * BLY_NULLABLE)exception {
NSString *message = [NSString stringWithFormat:@"%@ - %@", exception.name, exception.reason];
[self handleLagDetection:message];
return message;
}
#pragma mark - Public Methods
- (void)configureWithAppId:(NSString *)appId debug:(BOOL)isDebug {
if (self.isConfigured) {
NSLog(@"[BuglyManager] Bugly 已经配置,跳过重复配置");
return;
}
if (!appId || appId.length == 0) {
NSLog(@"[BuglyManager] 错误appId 不能为空");
return;
}
self.appId = appId;
// Bugly
BuglyConfig *config = [[BuglyConfig alloc] init];
config.delegate = self;
//
config.blockMonitorTimeout = 3.0; // 3
config.blockMonitorEnable = YES; //
//
if (isDebug) {
config.debugMode = NO; //
config.channel = [self getAppChannel];
config.reportLogLevel = BuglyLogLevelWarn; //
} else {
config.unexpectedTerminatingDetectionEnable = YES; // 退
config.debugMode = NO;
config.channel = [self getAppChannel];
config.blockMonitorEnable = YES;
config.reportLogLevel = BuglyLogLevelWarn;
}
// Bugly
[Bugly startWithAppId:appId config:config];
self.isConfigured = YES;
NSLog(@"[BuglyManager] Bugly 配置完成 - AppID: %@, Debug: %@", appId, isDebug ? @"YES" : @"NO");
}
- (void)reportError:(NSString *)domain
code:(NSInteger)code
userInfo:(NSDictionary *)userInfo {
if (!self.isConfigured) {
NSLog(@"[BuglyManager] 错误Bugly 未配置,无法上报错误");
return;
}
if (!domain || domain.length == 0) {
domain = @"UnknownError";
}
//
NSError *error = [NSError errorWithDomain:domain
code:code
userInfo:userInfo];
//
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
[Bugly reportError:error];
NSLog(@"[BuglyManager] 错误上报成功 - Domain: %@, Code: %ld", domain, (long)code);
});
}
- (void)reportBusinessError:(NSString *)message
code:(NSInteger)code
context:(NSDictionary *)context {
NSMutableDictionary *userInfo = [NSMutableDictionary dictionary];
//
if (message && message.length > 0) {
[userInfo setObject:message forKey:@"error_message"];
}
[userInfo setObject:@(code) forKey:@"error_code"];
[userInfo setObject:@"BusinessError" forKey:@"error_type"];
//
if (context && context.count > 0) {
[userInfo addEntriesFromDictionary:context];
}
//
[userInfo setObject:@([[NSDate date] timeIntervalSince1970]) forKey:@"timestamp"];
[self reportError:@"BusinessError" code:code userInfo:userInfo];
}
- (void)reportNetworkError:(NSString *)uid
api:(NSString *)api
code:(NSInteger)code
userInfo:(NSDictionary *)userInfo {
NSMutableDictionary *errorInfo = [NSMutableDictionary dictionary];
//
if (uid && uid.length > 0) {
[errorInfo setObject:uid forKey:@"user_id"];
}
if (api && api.length > 0) {
[errorInfo setObject:api forKey:@"api_path"];
}
[errorInfo setObject:@(code) forKey:@"http_code"];
[errorInfo setObject:@"NetworkError" forKey:@"error_type"];
//
[errorInfo setObject:[NSThread callStackSymbols] forKey:@"call_stack_symbols"];
//
if (userInfo && userInfo.count > 0) {
[errorInfo addEntriesFromDictionary:userInfo];
}
[self reportError:@"NetworkError" code:code userInfo:errorInfo];
}
- (void)reportIAPError:(NSString *)uid
transactionId:(NSString *)transactionId
orderId:(NSString *)orderId
status:(NSInteger)status
context:(NSDictionary *)context {
NSMutableDictionary *errorInfo = [NSMutableDictionary dictionary];
//
if (uid && uid.length > 0) {
[errorInfo setObject:uid forKey:@"user_id"];
}
if (transactionId && transactionId.length > 0) {
[errorInfo setObject:transactionId forKey:@"transaction_id"];
}
if (orderId && orderId.length > 0) {
[errorInfo setObject:orderId forKey:@"order_id"];
}
[errorInfo setObject:@(status) forKey:@"status_code"];
[errorInfo setObject:@"IAPError" forKey:@"error_type"];
//
NSString *statusMsg = [self getIAPStatusMessage:status];
if (statusMsg) {
[errorInfo setObject:statusMsg forKey:@"status_message"];
}
//
if (context && context.count > 0) {
[errorInfo addEntriesFromDictionary:context];
}
//
NSInteger errorCode = -20000 + status;
[self reportError:@"IAPError" code:errorCode userInfo:errorInfo];
}
- (void)startLagDetection {
if (!self.isConfigured) {
NSLog(@"[BuglyManager] 错误Bugly 未配置,无法启动卡顿检测");
return;
}
NSLog(@"[BuglyManager] 手动启动卡顿检测");
// Bugly
}
#pragma mark - Private Methods
- (void)handleLagDetection:(NSString *)stackTrace {
NSLog(@"[BuglyManager] 🚨 检测到卡顿 - StackTrace: %@", stackTrace);
// 3 Bugly
NSTimeInterval duration = 3.0;
//
if (self.delegate && [self.delegate respondsToSelector:@selector(buglyManager:didDetectLag:)]) {
dispatch_async(dispatch_get_main_queue(), ^{
[(id<BuglyManagerDelegate>)self.delegate buglyManager:self didDetectLag:duration];
});
}
// TODO:
// 1.
// 2.
// 3.
// 4.
}
- (NSString *)getAppChannel {
//
//
return @"AppStore";
}
- (NSString *)getIAPStatusMessage:(NSInteger)status {
switch (status) {
case 0:
return @"尝试验单";
case 1:
return @"验单-补单成功";
case 2:
return @"验单-补单失败";
case 3:
return @"验单-补单 id 异常";
case 4:
return @"重试次数过多";
case 5:
return @"过期交易清理";
default:
return @"未知状态";
}
}
@end

View File

@@ -0,0 +1,42 @@
//
// BuglyManagerExample.h
// YuMi
//
// Created by BuglyManager Example
// Copyright © 2024 YuMi. All rights reserved.
//
#import <Foundation/Foundation.h>
#import "BuglyManager.h"
NS_ASSUME_NONNULL_BEGIN
/**
* BuglyManager 使用示例类
* 展示如何使用 BuglyManager 的各种功能
*/
@interface BuglyManagerExample : NSObject <BuglyManagerDelegate>
/**
* 设置 Bugly 代理
*/
- (void)setupBuglyDelegate;
/**
* 上报业务错误示例
*/
- (void)reportBusinessErrorExample;
/**
* 上报网络错误示例
*/
- (void)reportNetworkErrorExample;
/**
* 上报内购错误示例
*/
- (void)reportIAPErrorExample;
@end
NS_ASSUME_NONNULL_END

View File

@@ -0,0 +1,79 @@
//
// BuglyManagerExample.m
// YuMi
//
// Created by BuglyManager Example
// Copyright © 2024 YuMi. All rights reserved.
//
#import "BuglyManagerExample.h"
#import "BuglyManager.h"
@implementation BuglyManagerExample
#pragma mark - 使
// 1
- (void)setupBuglyDelegate {
[BuglyManager sharedManager].delegate = self;
}
// 2
- (void)reportBusinessErrorExample {
NSDictionary *context = @{
@"page": @"HomePage",
@"action": @"loadData",
@"timestamp": @([[NSDate date] timeIntervalSince1970])
};
[[BuglyManager sharedManager] reportBusinessError:@"数据加载失败"
code:1001
context:context];
}
// 3
- (void)reportNetworkErrorExample {
NSDictionary *userInfo = @{
@"requestParams": @{@"userId": @"12345"},
@"responseData": @"服务器错误"
};
[[BuglyManager sharedManager] reportNetworkError:@"user123"
api:@"user/profile"
code:500
userInfo:userInfo];
}
// 4
- (void)reportIAPErrorExample {
NSDictionary *context = @{
@"retryCount": @3,
@"productId": @"com.yumi.coin100"
};
[[BuglyManager sharedManager] reportIAPError:@"user123"
transactionId:@"txn_123456"
orderId:@"order_789"
status:2
context:context];
}
#pragma mark - BuglyManagerDelegate
- (void)buglyManager:(BuglyManager *)manager didDetectLag:(NSTimeInterval)duration {
NSLog(@"[Example] 检测到卡顿,持续时间: %.2f 秒", duration);
// TODO:
// 1.
// 2.
// 3.
// 4.
}
- (void)buglyManager:(BuglyManager *)manager didDetectBlock:(NSTimeInterval)duration {
NSLog(@"[Example] 检测到主线程阻塞,持续时间: %.2f 秒", duration);
// TODO:
}
@end

View File

@@ -0,0 +1,129 @@
# BuglyManager 使用说明
## 概述
`BuglyManager` 是一个统一的 Bugly 管理类,封装了所有 Bugly 相关操作,提供统一的错误上报和性能监控接口。
## 主要功能
### 1. 统一错误上报
- 业务错误上报
- 网络错误上报
- 内购错误上报
- 自定义错误上报
### 2. 卡顿监听
- 自动检测主线程卡顿
- 支持代理回调通知
- 可配置卡顿阈值
### 3. 性能监控
- 主线程阻塞检测
- 异常退出检测
- 自定义日志级别
## 使用方法
### 1. 初始化配置
```objc
// 在 AppDelegate 中配置
#ifdef DEBUG
[[BuglyManager sharedManager] configureWithAppId:@"c937fd00f7" debug:YES];
#else
[[BuglyManager sharedManager] configureWithAppId:@"8627948559" debug:NO];
#endif
```
### 2. 设置代理监听卡顿
```objc
// 在需要监听卡顿的类中
@interface YourClass : NSObject <BuglyManagerDelegate>
@end
@implementation YourClass
- (void)setupBuglyDelegate {
[BuglyManager sharedManager].delegate = self;
}
- (void)buglyManager:(BuglyManager *)manager didDetectLag:(NSTimeInterval)duration {
NSLog(@"检测到卡顿,持续时间: %.2f 秒", duration);
// TODO: 实现卡顿通知逻辑
}
@end
```
### 3. 错误上报
#### 业务错误上报
```objc
NSDictionary *context = @{
@"page": @"HomePage",
@"action": @"loadData"
};
[[BuglyManager sharedManager] reportBusinessError:@"数据加载失败"
code:1001
context:context];
```
#### 网络错误上报
```objc
NSDictionary *userInfo = @{
@"requestParams": @{@"userId": @"12345"},
@"responseData": @"服务器错误"
};
[[BuglyManager sharedManager] reportNetworkError:@"user123"
api:@"user/profile"
code:500
userInfo:userInfo];
```
#### 内购错误上报
```objc
NSDictionary *context = @{
@"retryCount": @3,
@"productId": @"com.yumi.coin100"
};
[[BuglyManager sharedManager] reportIAPError:@"user123"
transactionId:@"txn_123456"
orderId:@"order_789"
status:2
context:context];
```
## 重构完成情况
### ✅ 已完成
1. 创建 `BuglyManager.h``BuglyManager.m`
2. 修改 `AppDelegate+ThirdConfig.m` 使用 BuglyManager
3. 修改 `IAPManager.m` 使用 BuglyManager
4. 修改 `HttpRequestHelper.m` 使用 BuglyManager
5. 修改 `GiftComboManager.m` 使用 BuglyManager
6. 创建使用示例和文档
### 🔄 进行中
1. 修改 `XPGiftPresenter.m` 使用 BuglyManager
### 📋 待完成
1. 测试验证所有功能
2. 完善卡顿通知逻辑
3. 性能优化
## 优势
1. **统一管理**:所有 Bugly 相关操作集中在一个类中
2. **降低耦合**:其他模块无需直接引入 Bugly 头文件
3. **易于维护**:统一的接口和错误处理逻辑
4. **功能扩展**:支持卡顿监听和自定义通知
5. **向后兼容**:保持现有功能完全不变
## 注意事项
1. 确保在真机环境下编译,模拟器可能无法正确导入 Bugly 头文件
2. 卡顿监听功能需要在实际设备上测试
3. 错误上报是异步操作,不会阻塞主线程
4. 建议在 AppDelegate 中尽早初始化 BuglyManager

View File

@@ -46,7 +46,7 @@ isPhoneXSeries = [[UIApplication sharedApplication] delegate].window.safeAreaIns
#define kFontHeavy(font) [UIFont systemFontOfSize:kGetScaleWidth(font) weight:UIFontWeightHeavy]
///内置版本号
#define PI_App_Version @"1.0.30"
#define PI_App_Version @"1.0.31"
///渠道
#define PI_App_Source @"appstore"
#define PI_Test_Flight @"TestFlight"

View File

@@ -808,6 +808,8 @@ typedef NS_ENUM(NSUInteger, CustomMessageTypeRoomLevelUpdate) {
@property(nonatomic, assign) NSInteger seq; // 本地序号,用于将一条消息分解为多条有次序的消息
@property (nonatomic, assign) BOOL isFromPublic;
@end
NS_ASSUME_NONNULL_END

View File

@@ -64,7 +64,7 @@
model.message.rawAttachContent];
}
QEmotionHelper *faceManager = [QEmotionHelper sharedEmotionHelper];
NSMutableAttributedString * attribute = [faceManager attributedStringByText:messageText font:[UIFont systemFontOfSize:13]];
NSMutableAttributedString * attribute = [faceManager attributedStringByText:messageText font:[UIFont systemFontOfSize:13] forMessageBubble:YES];
_messageText.attributedText = attribute;
}
[_messageText.superview layoutIfNeeded];

View File

@@ -48,7 +48,7 @@
CGSize dstRect = CGSizeMake(width, MAXFLOAT);
QEmotionHelper *faceManager = [QEmotionHelper sharedEmotionHelper];
NSMutableAttributedString * attribute = [faceManager attributedStringByText:messageText font:kFontMedium(14)];
NSMutableAttributedString * attribute = [faceManager attributedStringByText:messageText font:kFontMedium(14) forMessageBubble:YES];
if(extModel.iosBubbleUrl.length > 0){
[attribute addAttributes:@{NSForegroundColorAttributeName: UIColorFromRGB(0x333333)} range:[attribute.string rangeOfString:attribute.string]];
}else{

View File

@@ -34,7 +34,7 @@
}
CGSize dstRect = CGSizeMake(CONTENT_WIDTH_MAX - MESSAGE_PADDING * 2, MAXFLOAT);
QEmotionHelper *faceManager = [QEmotionHelper sharedEmotionHelper];
NSMutableAttributedString * attribute = [faceManager attributedStringByText:messageText font:[UIFont systemFontOfSize:13]];
NSMutableAttributedString * attribute = [faceManager attributedStringByText:messageText font:[UIFont systemFontOfSize:13] forMessageBubble:YES];
self.textAttribute = attribute;
YYTextContainer *container = [YYTextContainer containerWithSize:dstRect];
container.maximumNumberOfRows = 0;

View File

@@ -152,8 +152,10 @@
}
- (void)uploadGifAvatar:(NSData *)data {
NSString *format = [UIImage getImageTypeWithImageData:data];
NSString *name = [NSString stringWithFormat:@"image/%@.%@",[NSString createUUID],format];
//
NSString *format = [self.class detectMediaExtensionForData:data];
//
NSString *name = [NSString stringWithFormat:@"image/%@.%@", [NSString createUUID], format];
@kWeakify(self);
[[UploadFile share]QCloudUploadImage:data named:name success:^(NSString * _Nonnull key, NSDictionary * _Nonnull resp) {
@kStrongify(self);
@@ -163,6 +165,43 @@
}];
}
///
/// gifpngjpgmp4 ftyp box
/// 退使 jpg
+ (NSString *)detectMediaExtensionForData:(NSData *)data {
if (data.length < 12) {
return @"jpg";
}
const unsigned char *bytes = (const unsigned char *)data.bytes;
// GIF: 47 49 46 38 39|37 61 -> "GIF89a" / "GIF87a"
if (bytes[0] == 'G' && bytes[1] == 'I' && bytes[2] == 'F') {
return @"gif";
}
// PNG: 89 50 4E 47 0D 0A 1A 0A
const unsigned char pngSig[8] = {0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A};
if (memcmp(bytes, pngSig, 8) == 0) {
return @"png";
}
// JPEG: FF D8 FF
if (bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF) {
return @"jpg";
}
// MP4: offset 4..7 == 'f' 't' 'y' 'p'
if (data.length >= 12) {
if (bytes[4] == 'f' && bytes[5] == 't' && bytes[6] == 'y' && bytes[7] == 'p') {
return @"mp4";
}
}
//
return @"jpg";
}
///
-(void)getAreaList{
@kWeakify(self);

View File

@@ -10,6 +10,7 @@
#import <Masonry/Masonry.h>
#import <ReactiveObjC/ReactiveObjC.h>
#import <TZImagePickerController/TZImagePickerController.h>
#import <AVFoundation/AVFoundation.h>
///Tool
#import "TTPopup.h"
@@ -197,11 +198,14 @@ TZImagePickerControllerDelegate>
imagePickerVc.allowTakeVideo = NO;
if (displayGIF) {
imagePickerVc.allowTakePicture = NO;
//
imagePickerVc.allowCrop = NO;
} else {
imagePickerVc.allowCrop = YES;
CGFloat cropWidth = KScreenWidth;
imagePickerVc.cropRect = CGRectMake(0, (self.view.bounds.size.height - cropWidth) / 2, cropWidth, cropWidth);
}
imagePickerVc.allowPickingGif = displayGIF;
imagePickerVc.allowCrop = YES;
CGFloat cropWidth = KScreenWidth;
imagePickerVc.cropRect = CGRectMake(0, (self.view.bounds.size.height - cropWidth) / 2, cropWidth, cropWidth);
imagePickerVc.naviBgColor = [DJDKMIMOMColor appCellBackgroundColor];
imagePickerVc.naviTitleColor = [DJDKMIMOMColor mainTextColor];
imagePickerVc.barItemTextColor = [DJDKMIMOMColor mainTextColor];
@@ -418,8 +422,13 @@ TZImagePickerControllerDelegate>
#pragma mark - TZImagePickerControllerDelegate
- (BOOL)isAssetCanBeDisplayed:(PHAsset *)asset {
if (self.isPhotoPickerDisplayGIF) {
return [[asset valueForKey:@"filename"] containsString:@"GIF"];
// displayGIF YES GIF
NSString *filename = [asset valueForKey:@"filename"];
BOOL isGIF = [filename containsString:@"GIF"];
// BOOL isVideo = asset.mediaType == PHAssetMediaTypeVideo;
return isGIF;// || isVideo;
} else {
// displayGIF NO GIF
return ![[asset valueForKey:@"filename"] containsString:@"GIF"];
}
}
@@ -457,6 +466,164 @@ TZImagePickerControllerDelegate>
}
}
//
- (void)imagePickerController:(TZImagePickerController *)picker didFinishPickingVideo:(UIImage *)coverImage sourceAssets:(PHAsset *)asset {
@kWeakify(self);
dispatch_async(dispatch_get_main_queue(), ^{
@kStrongify(self);
[self showLoading];
[self processVideoAsset:asset];
[picker dismissViewControllerAnimated:YES completion:^{}];
});
}
//
- (void)processVideoAsset:(PHAsset *)asset {
@kWeakify(self);
// 5
NSTimeInterval duration = asset.duration;
if (duration > 555.0) {
dispatch_async(dispatch_get_main_queue(), ^{
@kStrongify(self);
[self hideHUD];
[self showErrorToast:@"视频时长不能超过555秒"];
});
return;
}
// AVAsset
[[PHImageManager defaultManager] requestAVAssetForVideo:asset
options:nil
resultHandler:^(AVAsset * _Nullable asset, AVAudioMix * _Nullable audioMix, NSDictionary * _Nullable info) {
@kStrongify(self);
if ([asset isKindOfClass:[AVURLAsset class]]) {
AVURLAsset *urlAsset = (AVURLAsset *)asset;
[self compressAndUploadVideo:urlAsset.URL];
} else {
dispatch_async(dispatch_get_main_queue(), ^{
[self hideHUD];
[self showErrorToast:@"视频格式不支持"];
});
}
}];
}
//
- (void)compressAndUploadVideo:(NSURL *)videoURL {
@kWeakify(self);
// URL
NSString *outputFileName = [NSString stringWithFormat:@"compressed_video_%ld.mov", (long)[[NSDate date] timeIntervalSince1970]];
NSString *outputPath = [NSTemporaryDirectory() stringByAppendingPathComponent:outputFileName];
NSURL *outputURL = [NSURL fileURLWithPath:outputPath];
//
if ([[NSFileManager defaultManager] fileExistsAtPath:outputPath]) {
[[NSFileManager defaultManager] removeItemAtPath:outputPath error:nil];
}
//
AVAssetExportSession *exportSession = [[AVAssetExportSession alloc] initWithAsset:[AVAsset assetWithURL:videoURL]
presetName:AVAssetExportPresetMediumQuality];
if (exportSession) {
exportSession.outputURL = outputURL;
exportSession.outputFileType = AVFileTypeMPEG4;
exportSession.shouldOptimizeForNetworkUse = YES;
//
exportSession.videoComposition = [self createVideoCompositionForAsset:[AVAsset assetWithURL:videoURL]];
[exportSession exportAsynchronouslyWithCompletionHandler:^{
@kStrongify(self);
dispatch_async(dispatch_get_main_queue(), ^{
if (exportSession.status == AVAssetExportSessionStatusCompleted) {
// 100MB
NSDictionary *fileAttributes = [[NSFileManager defaultManager] attributesOfItemAtPath:outputPath error:nil];
NSNumber *fileSize = [fileAttributes objectForKey:NSFileSize];
if (fileSize && fileSize.longLongValue > 100 * 1024 * 1024) { // 10MB
[self hideHUD];
[self showErrorToast:@"视频文件过大,请选择较短的视频"];
[[NSFileManager defaultManager] removeItemAtPath:outputPath error:nil];
return;
}
//
NSData *videoData = [NSData dataWithContentsOfURL:outputURL];
if (videoData) {
// 使 GIF
[self.presenter uploadGifAvatar:videoData];
} else {
[self hideHUD];
[self showErrorToast:@"视频数据读取失败"];
}
} else if (exportSession.status == AVAssetExportSessionStatusFailed) {
[self hideHUD];
[self showErrorToast:[NSString stringWithFormat:@"视频压缩失败: %@", exportSession.error.localizedDescription]];
} else if (exportSession.status == AVAssetExportSessionStatusCancelled) {
[self hideHUD];
[self showErrorToast:@"视频处理已取消"];
} else {
[self hideHUD];
[self showErrorToast:@"视频处理失败"];
}
//
[[NSFileManager defaultManager] removeItemAtPath:outputPath error:nil];
});
}];
} else {
dispatch_async(dispatch_get_main_queue(), ^{
[self hideHUD];
[self showErrorToast:@"视频处理失败"];
});
}
}
//
- (AVMutableVideoComposition *)createVideoCompositionForAsset:(AVAsset *)asset {
AVMutableVideoComposition *videoComposition = [AVMutableVideoComposition videoComposition];
//
AVAssetTrack *videoTrack = [[asset tracksWithMediaType:AVMediaTypeVideo] firstObject];
if (!videoTrack) {
return nil;
}
//
CGSize videoSize = videoTrack.naturalSize;
CGFloat size = MIN(videoSize.width, videoSize.height);
CGSize targetSize = CGSizeMake(size, size);
videoComposition.renderSize = targetSize;
videoComposition.frameDuration = CMTimeMake(1, 30); // 30fps
//
AVMutableVideoCompositionInstruction *instruction = [AVMutableVideoCompositionInstruction videoCompositionInstruction];
instruction.timeRange = CMTimeRangeMake(kCMTimeZero, asset.duration);
AVMutableVideoCompositionLayerInstruction *layerInstruction = [AVMutableVideoCompositionLayerInstruction videoCompositionLayerInstructionWithAssetTrack:videoTrack];
// 使
CGFloat scaleX = targetSize.width / videoSize.width;
CGFloat scaleY = targetSize.height / videoSize.height;
CGFloat scale = MAX(scaleX, scaleY);
CGAffineTransform transform = CGAffineTransformMakeScale(scale, scale);
// 使
CGFloat offsetX = (targetSize.width - videoSize.width * scale) / 2;
CGFloat offsetY = (targetSize.height - videoSize.height * scale) / 2;
transform = CGAffineTransformTranslate(transform, offsetX / scale, offsetY / scale);
[layerInstruction setTransform:transform atTime:kCMTimeZero];
instruction.layerInstructions = @[layerInstruction];
videoComposition.instructions = @[instruction];
return videoComposition;
}
#pragma mark - UIImagePickerControllerDelegate
- (void)imagePickerController:(UIImagePickerController *)picker didFinishPickingMediaWithInfo:(NSDictionary *)info
{

View File

@@ -7,6 +7,7 @@
#import "IAPManager.h"
#import <Bugly/Bugly.h>
#import "BuglyManager.h"
#import "Api+Mine.h"
#import "YuMi-swift.h"
#import "RechargeStorage.h"
@@ -232,68 +233,23 @@
}
- (void)_logToBugly:(NSString *)tid oID:(NSString *)oid status:(NSInteger)status {
NSMutableDictionary *logDic = [NSMutableDictionary dictionary];
// ID
if ([NSString isEmpty:tid]) {
[logDic setObject:@"" forKey:@"内购 transactionId"];
tid = @"";
} else {
[logDic setObject:tid forKey:@"内购 transactionId"];
}
// ID
if ([NSString isEmpty:oid]) {
[logDic setObject:@"" forKey:@"内购 orderId"];
} else {
[logDic setObject:oid forKey:@"内购 orderId"];
}
// ID
// 使 BuglyManager
NSString *uid = [AccountInfoStorage instance].getUid ?: @"未知用户";
[logDic setObject:uid forKey:@"内购 用户id"];
//
[logDic setObject:@([self getRetryCountForTransaction:tid]) forKey:@"重试次数"];
[logDic setObject:@(self.recheckInterval) forKey:@"重试间隔"];
[logDic setObject:@(self.recheckIndex) forKey:@"当前索引"];
[logDic setObject:@(self.isProcessing) forKey:@"处理中状态"];
[logDic setObject:@(self.isLogin) forKey:@"登录状态"];
//
NSMutableDictionary *context = [NSMutableDictionary dictionary];
[context setObject:@([self getRetryCountForTransaction:tid]) forKey:@"重试次数"];
[context setObject:@(self.recheckInterval) forKey:@"重试间隔"];
[context setObject:@(self.recheckIndex) forKey:@"当前索引"];
[context setObject:@(self.isProcessing) forKey:@"处理中状态"];
[context setObject:@(self.isLogin) forKey:@"登录状态"];
NSString *statusMsg = @"";
NSInteger code = -20000;
switch (status) {
case 0:
statusMsg = [NSString stringWithFormat:@"UID: %@, 尝试验单", uid];
break;
case 1:
statusMsg = [NSString stringWithFormat:@"UID: %@, 验单-补单成功", uid];
code = -20001;
break;
case 2:
statusMsg = [NSString stringWithFormat:@"UID: %@, 验单-补单失败", uid];
code = -20002;
break;
case 3:
statusMsg = [NSString stringWithFormat:@"UID: %@, 验单-补单 id 异常", uid];
code = -20002;
break;
case 4:
statusMsg = [NSString stringWithFormat:@"UID: %@, 重试次数过多", uid];
code = -20003;
break;
case 5:
statusMsg = [NSString stringWithFormat:@"UID: %@, 过期交易清理", uid];
code = -20004;
break;
default:
break;
}
dispatch_async(dispatch_get_global_queue(0, 0), ^{
[Bugly reportError:[NSError errorWithDomain:statusMsg
code:code
userInfo:logDic]];
});
// 使 BuglyManager
[[BuglyManager sharedManager] reportIAPError:uid
transactionId:tid
orderId:oid
status:status
context:context];
}
//

View File

@@ -266,8 +266,9 @@
if ([[XPSkillCardPlayerManager shareInstance] isInRoomVC]) {
[self.boomEventsQueue addObject:attachment];
[[NSNotificationCenter defaultCenter] postNotificationName:@"UpdateWhenBoomExplosion" object:nil];
[self.bannerEventsQueue addObject:attachment];
}
[self.bannerEventsQueue addObject:attachment];
// [self.bannerEventsQueue addObject:attachment];
[self checkAndStartBoomEvent];
[self checkAndStartBannerEvent];

View File

@@ -0,0 +1,23 @@
//
// GameBannerGestureManager.h
// YuMi
//
// Created by P on 2025/8/27.
//
#import <UIKit/UIKit.h>
NS_ASSUME_NONNULL_BEGIN
@interface GameBannerGestureManager : NSObject
@property (nonatomic, weak) UIView *bannerContainer;
@property (nonatomic, assign, readonly) BOOL isEnabled;
- (void)enableGestureForGameMode;
- (void)disableGestureForGameMode;
- (void)cleanupAllGestures;
- (void)resetState;
@end
NS_ASSUME_NONNULL_END

View File

@@ -0,0 +1,89 @@
// GameBannerGestureManager.m
#import "GameBannerGestureManager.h"
@interface GameBannerGestureManager ()
@property (nonatomic, assign) BOOL isEnabled;
@property (nonatomic, strong) UISwipeGestureRecognizer *swipeGesture;
@end
@implementation GameBannerGestureManager
- (void)enableGestureForGameMode {
if (self.isEnabled) return;
[self cleanupAllGestures];
// swipe
self.swipeGesture = [[UISwipeGestureRecognizer alloc]
initWithTarget:self action:@selector(handleSwipe)];
//
self.swipeGesture.delaysTouchesBegan = NO;
self.swipeGesture.delaysTouchesEnded = NO;
self.swipeGesture.cancelsTouchesInView = NO;
self.swipeGesture.requiresExclusiveTouchType = NO;
// swipe
if (isMSRTL()) {
self.swipeGesture.direction = UISwipeGestureRecognizerDirectionRight;
} else {
self.swipeGesture.direction = UISwipeGestureRecognizerDirectionLeft;
}
[self.bannerContainer addGestureRecognizer:self.swipeGesture];
_isEnabled = YES;
NSLog(@"🎮 GameBannerGestureManager: swipe 手势已启用");
}
- (void)disableGestureForGameMode {
if (!self.isEnabled) return;
[self cleanupAllGestures];
_isEnabled = NO;
NSLog(@"🎮 GameBannerGestureManager: swipe 手势已禁用");
}
- (void)cleanupAllGestures {
if (self.swipeGesture) {
[self.bannerContainer removeGestureRecognizer:self.swipeGesture];
self.swipeGesture = nil;
}
//
NSArray *gestures = [self.bannerContainer.gestureRecognizers copy];
for (UIGestureRecognizer *gesture in gestures) {
if ([gesture isKindOfClass:[UISwipeGestureRecognizer class]]) {
[self.bannerContainer removeGestureRecognizer:gesture];
}
}
}
- (void)resetState {
[self cleanupAllGestures];
_isEnabled = NO;
}
- (void)handleSwipe {
NSLog(@"🎮 GameBannerGestureManager: 检测到 swipe 手势,发送 banner 移除通知");
// banner
UIView *currentVisibleBanner = nil;
for (UIView *subview in self.bannerContainer.subviews) {
if (!subview.hidden && subview.alpha > 0.01) {
currentVisibleBanner = subview;
break;
}
}
if (currentVisibleBanner) {
NSLog(@"🎮 当前有可见 banner: %@,发送 SwipeOutBanner 通知", NSStringFromClass([currentVisibleBanner class]));
[[NSNotificationCenter defaultCenter] postNotificationName:@"SwipeOutBanner"
object:currentVisibleBanner];
} else {
NSLog(@"🎮 当前没有可见 banner忽略 swipe 手势");
}
}
@end

View File

@@ -23,6 +23,8 @@
@property (nonatomic, copy) NSString *currentUserId;
@property (nonatomic, strong) UserInfoModel *userInfo;
@end
@implementation PublicRoomManager
@@ -217,6 +219,7 @@
NSMutableDictionary *ext = [NSMutableDictionary dictionaryWithObject:extModel.model2dictionary
forKey:[NSString stringWithFormat:@"%ld", self.userInfo.uid]];
request.roomExt = [ext toJSONString];
request.retryCount = 3;
//
@kWeakify(self);
@@ -373,37 +376,59 @@
- (void)onRecvMessages:(NSArray<NIMMessage *> *)messages {
//
for (NIMMessage *message in messages) {
if ([message.session.sessionId isEqualToString:self.currentPublicRoomId]) {
[self handleMessageWithAttachmentAndFirstSecond:message];
}
/*
if (message.session.sessionType == NIMSessionTypeChatroom) {
NSString *sessionId = message.session.sessionId;
if ([sessionId isEqualToString:self.currentPublicRoomId]) {
NIMMessageChatroomExtension *messageExt = (NIMMessageChatroomExtension *)message.messageExt;
AttachmentModel *attachment;
if (message.messageType == NIMMessageTypeCustom) {
NIMCustomObject *obj = (NIMCustomObject *) message.messageObject;
attachment = (AttachmentModel *) obj.attachment;
if (attachment) {
switch (attachment.first) {
case CustomMessageType_Super_Gift:
[self handleFirst_106:attachment
message:message];
break;
default:
break;
}
if (attachment.first > 0 && attachment.second >0) {
attachment.isFromPublic = YES;
[self handleMessageWithAttachmentAndFirstSecond:message];
}
// if (attachment) {
// switch (attachment.first) {
// case CustomMessageType_Super_Gift:
// [self handleFirst_106:attachment
// message:message];
// break;
// default:
// break;
// }
// }
}
// NIMMessageChatroomExtension *messageExt = (NIMMessageChatroomExtension *)message.messageExt;
// NSLog(@"PublicRoomManager: 收到公共房间消息: %@\n%@",
// message.rawAttachContent,
// messageExt.roomExt);
}
}
*/
}
}
- (void)handleMessageWithAttachmentAndFirstSecond:(NIMMessage *)message {
//
if (![XPSkillCardPlayerManager shareInstance].isInRoom) {
NSLog(@"PublicRoomManager: 用户未在房间中,跳过消息转发");
return;
}
[[NSNotificationCenter defaultCenter] postNotificationName:@"MessageFromPublicRoomWithAttachmentNotification"
object:message];
}
- (void)handleFirst_106:(AttachmentModel *)attachment
message:(NIMMessage *)message {
// allRoomMsg
//
if (![XPSkillCardPlayerManager shareInstance].isInRoom) {
NSLog(@"PublicRoomManager: 用户未在房间中,跳过消息转发");

View File

@@ -30,7 +30,7 @@
+ (BravoGiftWinningFlagViewModel *)display:(UIView *)superView with:(AttachmentModel *)attachment roomID:(NSInteger)roomID uID:(NSString *)UID {
BravoGiftWinningFlagViewModel *model = [BravoGiftWinningFlagViewModel modelWithJSON:attachment.data];
if (model.roomId != roomID || model.uid != UID.integerValue || model.tip == nil) {
if (model.roomId != roomID || model.uid != UID.integerValue || !model.tip) {
return model;
}
@@ -38,8 +38,27 @@
flagView.model = model;
flagView.alpha = 0;
flagView.frame = CGRectMake(0, 0, kGetScaleWidth(274), kGetScaleWidth(216));
flagView.center = CGPointMake(superView.center.x, kGetScaleWidth(216) + kGetScaleWidth(216)/2 - 40);
// 🔧
CGFloat viewWidth = kGetScaleWidth(274);
CGFloat viewHeight = kGetScaleWidth(216);
CGFloat screenWidth = KScreenWidth;
CGFloat screenHeight = KScreenHeight;
// Y 40%
CGFloat safeY = screenHeight * 0.4;
//
CGFloat maxY = screenHeight - viewHeight/2 - 20; // 20
CGFloat minY = viewHeight/2 + 20; // 20
CGFloat finalY = MAX(minY, MIN(safeY, maxY));
flagView.center = CGPointMake(screenWidth/2, finalY);
flagView.transform = CGAffineTransformMakeScale(0.1, 0.1);
NSLog(@"🎯 BravoGiftWinningFlagView: 位置计算 - screenSize: %.0fx%.0f, viewSize: %.0fx%.0f, center: (%.0f, %.0f)",
screenWidth, screenHeight, viewWidth, viewHeight, flagView.center.x, flagView.center.y);
[superView addSubview:flagView];
// 使alpha01

View File

@@ -6,6 +6,7 @@
//
#import <Foundation/Foundation.h>
@class UIView;
@class GiftReceiveInfoModel;
NS_ASSUME_NONNULL_BEGIN
@@ -52,6 +53,13 @@ NS_ASSUME_NONNULL_BEGIN
- (void)enqueueGift:(GiftReceiveInfoModel *)giftInfo;
- (void)startGiftQueue;
- (void)stopGiftQueue;
// 🔧 新增Combo状态管理方法
- (void)setUserComboState:(BOOL)isCombo forUser:(NSString *)uid;
- (void)clearUserComboState:(NSString *)uid;
- (void)updateUserGiftTime:(NSString *)uid;
- (void)cleanupExpiredStates;
@end
NS_ASSUME_NONNULL_END

View File

@@ -20,6 +20,11 @@
@property (nonatomic, strong) GiftAnimationHelper *animationHelper;
// 🔧 Combo
@property (nonatomic, strong) NSMutableDictionary<NSString *, NSNumber *> *userComboStates;
@property (nonatomic, strong) NSMutableDictionary<NSString *, NSDate *> *userLastGiftTime;
@property (nonatomic, assign) NSTimeInterval comboTimeWindow;
@end
@implementation GiftAnimationManager
@@ -32,6 +37,11 @@
dispatch_source_cancel(_giftTimer);
_giftTimer = nil;
}
// 🔧 combo
[self cleanupExpiredStates];
[self.userComboStates removeAllObjects];
[self.userLastGiftTime removeAllObjects];
}
- (instancetype)initWithContainerView:(UIView *)containerView {
@@ -44,6 +54,11 @@
_comboAnimationDelay = 0.2;
_standardAnimationDelay = 0.3;
_queue = dispatch_queue_create("com.GiftAnimationManager.queue", DISPATCH_QUEUE_SERIAL);
// 🔧 Combo
_userComboStates = [NSMutableDictionary dictionary];
_userLastGiftTime = [NSMutableDictionary dictionary];
_comboTimeWindow = 2.0; // 2combo
}
return self;
}
@@ -245,8 +260,31 @@
// Helper methods
- (BOOL)shouldUseComboAnimationForSender:(NSString *)uid {
return [[GiftComboManager sharedManager] isActive] &&
[uid isEqualToString:[AccountInfoStorage instance].getUid];
if (!uid || uid.length == 0) {
return NO;
}
// 使
BOOL isUserInCombo = [self.userComboStates[uid] boolValue];
if (isUserInCombo) {
BOOL isCurrentUser = [uid isEqualToString:[AccountInfoStorage instance].getUid];
NSLog(@"[Combo effect] 🎯 用户 %@ 处于combo状态是否当前用户: %@", uid, isCurrentUser ? @"YES" : @"NO");
return isCurrentUser;
}
//
NSDate *lastGiftTime = self.userLastGiftTime[uid];
if (lastGiftTime) {
NSTimeInterval timeSinceLastGift = [[NSDate date] timeIntervalSinceDate:lastGiftTime];
if (timeSinceLastGift <= self.comboTimeWindow) {
BOOL isCurrentUser = [uid isEqualToString:[AccountInfoStorage instance].getUid];
NSLog(@"[Combo effect] 🎯 用户 %@ 在时间窗口内,是否当前用户: %@", uid, isCurrentUser ? @"YES" : @"NO");
return isCurrentUser;
}
}
NSLog(@"[Combo effect] 🎯 用户 %@ 不使用combo动画", uid);
return NO;
}
- (CGPoint)fallbackPointForEndPoint:(BOOL)isEndPoint {
@@ -268,4 +306,51 @@
KScreenHeight - kSafeAreaBottomHeight - kGetScaleWidth(140));
}
// 🔧 Combo
- (void)setUserComboState:(BOOL)isCombo forUser:(NSString *)uid {
if (!uid || uid.length == 0) {
NSLog(@"[Combo effect] ⚠️ 用户ID为空无法设置combo状态");
return;
}
if (isCombo) {
self.userComboStates[uid] = @(YES);
NSLog(@"[Combo effect] ✅ 设置用户 %@ 为combo状态", uid);
} else {
[self.userComboStates removeObjectForKey:uid];
NSLog(@"[Combo effect] 🔄 清除用户 %@ 的combo状态", uid);
}
}
- (void)clearUserComboState:(NSString *)uid {
[self setUserComboState:NO forUser:uid];
}
- (void)updateUserGiftTime:(NSString *)uid {
if (!uid || uid.length == 0) {
return;
}
self.userLastGiftTime[uid] = [NSDate date];
NSLog(@"[Combo effect] ⏰ 更新用户 %@ 的送礼时间", uid);
}
- (void)cleanupExpiredStates {
NSDate *now = [NSDate date];
NSMutableArray *expiredUsers = [NSMutableArray array];
[self.userLastGiftTime enumerateKeysAndObjectsUsingBlock:^(NSString *uid, NSDate *lastTime, BOOL *stop) {
if ([now timeIntervalSinceDate:lastTime] > self.comboTimeWindow * 2) {
[expiredUsers addObject:uid];
}
}];
for (NSString *uid in expiredUsers) {
[self.userLastGiftTime removeObjectForKey:uid];
[self.userComboStates removeObjectForKey:uid];
NSLog(@"[Combo effect] 🧹 清理过期用户状态: %@", uid);
}
}
@end

View File

@@ -54,8 +54,27 @@
winningFlagView.model = model;
winningFlagView.alpha = 0;
winningFlagView.frame = CGRectMake(0, 0, kGetScaleWidth(162), kGetScaleWidth(162));
winningFlagView.center = CGPointMake(superView.center.x, kGetScaleWidth(163) + kGetScaleWidth(162)/2);
// 🔧
CGFloat viewWidth = kGetScaleWidth(162);
CGFloat viewHeight = kGetScaleWidth(162);
CGFloat screenWidth = KScreenWidth;
CGFloat screenHeight = KScreenHeight;
// Y 35%
CGFloat safeY = screenHeight * 0.35;
//
CGFloat maxY = screenHeight - viewHeight/2 - 20; // 20
CGFloat minY = viewHeight/2 + 20; // 20
CGFloat finalY = MAX(minY, MIN(safeY, maxY));
winningFlagView.center = CGPointMake(screenWidth/2, finalY);
winningFlagView.transform = CGAffineTransformMakeScale(0.1, 0.1);
NSLog(@"🎯 LuckyGiftWinningFlagView: 位置计算 - screenSize: %.0fx%.0f, viewSize: %.0fx%.0f, center: (%.0f, %.0f)",
screenWidth, screenHeight, viewWidth, viewHeight, winningFlagView.center.x, winningFlagView.center.y);
[superView addSubview:winningFlagView];
// 使alpha01

View File

@@ -79,6 +79,7 @@
#import "MSRoomOnLineView.h"
#import "BannerScheduler.h"
#import "GameBannerGestureManager.h"
static const CGFloat kTipViewStayDuration = 3.0;
//static const CGFloat kTipViewMoveDuration = 0.5;
@@ -176,6 +177,12 @@ BannerSchedulerDelegate
// Banner
@property(nonatomic, assign) CGPoint savedTapPoint;
@property(nonatomic, assign) BOOL hasSavedTapPoint;
@property (nonatomic, strong) GameBannerGestureManager *gameGestureManager;
@property (nonatomic, assign) BOOL isGameModeActive;
@property (nonatomic, strong) UISwipeGestureRecognizer *swipeWhenPlayGame;
@end
@implementation RoomAnimationView
@@ -193,55 +200,29 @@ BannerSchedulerDelegate
- (void)removeItSelf {
NSLog(@"<22><> RoomAnimationView: 开始销毁");
// 🔧
[self cancelAllAnimations];
// 广
[[NIMSDK sharedSDK].broadcastManager removeDelegate:self];
//
[NSObject cancelPreviousPerformRequestsWithTarget:self];
// POP
[self pop_removeAllAnimations];
//
if (_giftEffectTimer && !dispatch_source_testcancel(_giftEffectTimer)) {
dispatch_source_cancel(_giftEffectTimer);
_giftEffectTimer = nil;
}
//
if (_giftEffectsQueue) {
self.giftEffectsQueue = NULL;
}
// <EFBFBD><EFBFBD> banner block
[self cleanupAllSubviews];
// 🔧 BannerScheduler
if (self.bannerScheduler) {
NSLog(@"<22><> 清理 BannerScheduler");
[self.bannerScheduler clearQueue];
[self.bannerScheduler pause];
self.bannerScheduler = nil;
}
//
[self cleanupGestureRecognizers];
//
[self cleanupCacheManagers];
//
[self removeNotificationObservers];
NSLog(@"<22><> RoomAnimationView: 销毁完成");
[self smartDestroy];
}
- (void)cancelAllAnimations {
NSLog(@" 取消所有动画");
NSLog(@"<EFBFBD><EFBFBD> 取消所有动画");
// <EFBFBD><EFBFBD>
BOOL hasImportantAnimation = [self hasImportantAnimationPlaying];
if (hasImportantAnimation) {
NSLog(@"⚠️ 检测到重要动画正在播放,延迟清理");
//
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(3.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
[self forceCancelAllAnimations];
});
return;
}
[self forceCancelAllAnimations];
}
- (void)forceCancelAllAnimations {
NSLog(@"🔄 强制取消所有动画");
// POP
[self pop_removeAllAnimations];
@@ -262,7 +243,25 @@ BannerSchedulerDelegate
}
- (void)cleanupAllSubviews {
NSLog(@" 清理所有子视图");
NSLog(@"🔄 清理所有子视图");
// <EFBFBD><EFBFBD>
BOOL hasImportantAnimation = [self hasImportantAnimationPlaying];
if (hasImportantAnimation) {
NSLog(@"⚠️ 检测到重要动画正在播放,延迟清理子视图");
//
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(3.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
[self forceCleanupAllSubviews];
});
return;
}
[self forceCleanupAllSubviews];
}
- (void)forceCleanupAllSubviews {
NSLog(@"<22><> 强制清理所有子视图");
//
NSArray *containers = @[self.bannerContainer, self.topContainer, self.middleContainer, self.bottomContainer];
@@ -273,7 +272,7 @@ BannerSchedulerDelegate
NSMutableArray *viewsToRemove = [NSMutableArray array];
for (UIView *subview in container.subviews) {
[viewsToRemove addObject:subview];
NSLog(@" 标记移除视图: %@ (从容器: %@)", NSStringFromClass([subview class]), NSStringFromClass([container class]));
NSLog(@"<EFBFBD><EFBFBD> 标记移除视图: %@ (从容器: %@)", NSStringFromClass([subview class]), NSStringFromClass([container class]));
}
for (UIView *view in viewsToRemove) {
@@ -282,7 +281,7 @@ BannerSchedulerDelegate
//
if (view.gestureRecognizers.count > 0) {
NSLog(@" 清理视图 %@ 上的 %lu 个手势识别器", NSStringFromClass([view class]), (unsigned long)view.gestureRecognizers.count);
NSLog(@"<EFBFBD><EFBFBD> 清理视图 %@ 上的 %lu 个手势识别器", NSStringFromClass([view class]), (unsigned long)view.gestureRecognizers.count);
for (UIGestureRecognizer *gesture in view.gestureRecognizers.copy) {
[view removeGestureRecognizer:gesture];
}
@@ -360,6 +359,28 @@ BannerSchedulerDelegate
[self setupOldMethods];
[self addBnnerContainGesture];
// 🔧
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(handleRoomTypeChanged:)
name:@"RoomTypeChanged"
object:nil];
// 🔧 combo
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(handleComboStateChanged:)
name:@"GiftComboStateChanged"
object:nil];
// 🎮
self.gameGestureManager = [[GameBannerGestureManager alloc] init];
self.gameGestureManager.bannerContainer = self.bannerContainer;
// 🎮
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(handleRoomTypeChanged:)
name:@"RoomTypeChanged"
object:nil];
}
- (void)setupUI {
@@ -790,6 +811,12 @@ BannerSchedulerDelegate
NSLog(@"🔄 BravoGiftBannerView complete 回调被调用");
self.isRoomBannerV2Displaying = NO;
[self.bannerScheduler markBannerFinished];
// 🔧
[self ensureBannerGestureContainersEnabled];
// 🔧
[self resetTouchState];
} exitCurrentRoom:^{
@kStrongify(self);
if (!self || !self.superview) {
@@ -817,6 +844,10 @@ BannerSchedulerDelegate
NSLog(@"🔄 LuckyPackageBannerView complete 回调被调用");
self.isRoomBannerV2Displaying = NO;
[self.bannerScheduler markBannerFinished];
// 🔧
[self ensureBannerGestureContainersEnabled];
[self resetTouchState];
} exitCurrentRoom:^{
@kStrongify(self);
[self.hostDelegate exitRoom];
@@ -842,6 +873,10 @@ BannerSchedulerDelegate
NSLog(@"🔄 RoomHighValueGiftBannerAnimation complete 回调被调用");
self.isRoomBannerV2Displaying = NO;
[self.bannerScheduler markBannerFinished];
// 🔧
[self ensureBannerGestureContainersEnabled];
[self resetTouchState];
}];
}
@@ -857,6 +892,10 @@ BannerSchedulerDelegate
@kStrongify(self);
self.isRoomBannerV2Displaying = NO;
[self.bannerScheduler markBannerFinished];
// 🔧
[self ensureBannerGestureContainersEnabled];
[self resetTouchState];
}];
}
@@ -869,6 +908,10 @@ BannerSchedulerDelegate
NSLog(@"🔄 CPGiftBanner complete 回调被调用");
self.isRoomBannerV2Displaying = NO;
[self.bannerScheduler markBannerFinished];
// 🔧
[self ensureBannerGestureContainersEnabled];
[self resetTouchState];
}];
}
@@ -880,6 +923,10 @@ BannerSchedulerDelegate
@kStrongify(self);
self.isRoomBannerV2Displaying = NO;
[self.bannerScheduler markBannerFinished];
// 🔧
[self ensureBannerGestureContainersEnabled];
[self resetTouchState];
}];
}
@@ -1043,6 +1090,10 @@ BannerSchedulerDelegate
NSLog(@"🔄 LuckyGiftWinningBannerView complete 回调被调用");
self.isRoomBannerV2Displaying = NO;
[self.bannerScheduler markBannerFinished];
// 🔧
[self ensureBannerGestureContainersEnabled];
[self resetTouchState];
} exitCurrentRoom:^{
@kStrongify(self);
[self.hostDelegate exitRoom];
@@ -1082,6 +1133,10 @@ BannerSchedulerDelegate
NSLog(@"🔄 GameUniversalBannerView complete 回调被调用");
self.isRoomBannerV2Displaying = NO;
[self.bannerScheduler markBannerFinished];
// 🔧
[self ensureBannerGestureContainersEnabled];
[self resetTouchState];
} goToGame:^(NSInteger gameID) {
@kStrongify(self);
NSArray *baishunList = [self.hostDelegate getPlayList];
@@ -1203,6 +1258,9 @@ BannerSchedulerDelegate
return;
}
// 🔧
[self.giftAnimationManager updateUserGiftTime:receiveInfo.uid];
GiftInfoModel *giftInfo = receiveInfo.gift;
if (attachment.second == Custom_Message_Sub_AllMicroLuckySend ||
attachment.second == Custom_Message_Sub_AllBatchMicroLuckySend ||
@@ -1241,9 +1299,7 @@ BannerSchedulerDelegate
if (receiveInfo.isHomeShow) {
return;
}
// combo
[[GiftComboManager sharedManager] receiveGiftInfoForDisplayComboFlags:receiveInfo
container:self];
@@ -2396,7 +2452,28 @@ BannerSchedulerDelegate
- (void)handleBannerTap:(UITapGestureRecognizer *)tapGesture {
CGPoint tapPoint = [tapGesture locationInView:self.bannerContainer];
// banner tap
// 🔧 banner
BOOL hasVisibleBanner = NO;
for (UIView *subview in self.bannerContainer.subviews) {
if (!subview.hidden && subview.alpha > 0.01) {
hasVisibleBanner = YES;
break;
}
}
// banner
if (!hasVisibleBanner) {
NSLog(@"🎯 没有可见 banner直接转发点击事件到下层");
self.savedTapPoint = tapPoint;
self.hasSavedTapPoint = YES;
CGPoint screenPoint = [self.bannerContainer convertPoint:tapPoint toView:nil];
[[NSNotificationCenter defaultCenter] postNotificationName:@"BannerTapToFunctionContainer"
object:nil
userInfo:@{@"point": [NSValue valueWithCGPoint:screenPoint]}];
return;
}
// banner
if ([self isPointInBannerInteractiveArea:tapPoint]) {
// banner banner
NSLog(@"🎯 Banner tap 位置在可交互区域banner 将处理此事件");
@@ -2410,7 +2487,6 @@ BannerSchedulerDelegate
NSLog(@"💾 Banner tap 位置不在可交互区域,已保存位置: %@", NSStringFromCGPoint(tapPoint));
// bannerContainer
CGPoint screenPoint = [self.bannerContainer convertPoint:tapPoint toView:nil];
// UIView *tappedView = tapGesture.view;
[[NSNotificationCenter defaultCenter] postNotificationName:@"BannerTapToFunctionContainer"
object:nil
userInfo:@{@"point": [NSValue valueWithCGPoint:screenPoint]}];
@@ -3727,6 +3803,9 @@ BannerSchedulerDelegate
[[NSNotificationCenter defaultCenter] removeObserver:self name:@"TapBanner" object:nil];
[[NSNotificationCenter defaultCenter] removeObserver:self name:@"BannerTapToFunctionContainer" object:nil];
// 🔧
[[NSNotificationCenter defaultCenter] removeObserver:self name:@"RoomTypeChanged" object:nil];
//
[[NSNotificationCenter defaultCenter] removeObserver:self];
@@ -3753,6 +3832,10 @@ BannerSchedulerDelegate
- (void)bannerSchedulerDidFinishPlaying:(BannerScheduler *)scheduler {
// Banner
NSLog(@"🔄 BannerScheduler: Banner 播放完成");
// 🔧
[self ensureBannerGestureContainersEnabled];
[self resetTouchState];
}
- (void)bannerScheduler:(BannerScheduler *)scheduler didStartPlayingBanner:(id)banner {
@@ -3823,4 +3906,208 @@ BannerSchedulerDelegate
}
}
#pragma mark -
//
- (void)handleRoomTypeChanged:(NSNotification *)notification {
NSDictionary *userInfo = notification.userInfo;
NSInteger roomType = [userInfo[@"roomType"] integerValue];
if (roomType == RoomType_MiniGame) {
NSLog(@" RoomAnimationView: 检测到小游戏房间,设置 banner 手势穿透模式 + 启用 swipe 手势");
[self setBannerGesturePassthroughMode];
self.isGameModeActive = YES;
} else {
NSLog(@" RoomAnimationView: 检测到非小游戏房间,恢复 banner 手势正常模式 + 移除 swipe 手势");
[self restoreBannerGestureNormalMode];
self.isGameModeActive = NO;
}
}
// 穿
- (void)setBannerGesturePassthroughMode {
//
[self hideAllGestureContainers];
// bannerContainer
self.bannerContainer.userInteractionEnabled = YES;
// swipe
[self.gameGestureManager enableGestureForGameMode];
NSLog(@"<22><> Banner 手势已设置为穿透模式swipe 手势已启用");
}
//
- (void)restoreBannerGestureNormalMode {
//
[self showAllGestureContainers];
// bannerContainer
self.bannerContainer.userInteractionEnabled = YES;
// swipe
[self.gameGestureManager disableGestureForGameMode];
NSLog(@"🎮 Banner 手势已恢复正常模式swipe 手势已移除");
}
- (void)hideAllGestureContainers {
self.bannerSwipeGestureContainer.hidden = YES;
self.bannerLeftTapGestureContainer.hidden = YES;
self.bannerRightTapGestureContainer.hidden = YES;
self.bannerSwipeGestureContainer.userInteractionEnabled = NO;
self.bannerLeftTapGestureContainer.userInteractionEnabled = NO;
self.bannerRightTapGestureContainer.userInteractionEnabled = NO;
}
- (void)showAllGestureContainers {
self.bannerSwipeGestureContainer.hidden = NO;
self.bannerLeftTapGestureContainer.hidden = NO;
self.bannerRightTapGestureContainer.hidden = NO;
self.bannerSwipeGestureContainer.userInteractionEnabled = YES;
self.bannerLeftTapGestureContainer.userInteractionEnabled = YES;
self.bannerRightTapGestureContainer.userInteractionEnabled = YES;
}
//
- (void)ensureBannerGestureContainersEnabled {
if (self.isGameModeActive) {
// swipe
if (!self.gameGestureManager.isEnabled) {
NSLog(@"🔧 检测到小游戏手势管理器未启用,重新启用(包含 swipe 手势)");
[self.gameGestureManager enableGestureForGameMode];
}
return;
}
// swipe
[self showAllGestureContainers];
//
if (self.gameGestureManager.isEnabled) {
NSLog(@"<22><> 检测到小游戏手势管理器仍启用,禁用(移除 swipe 手势)");
[self.gameGestureManager disableGestureForGameMode];
}
}
//
- (void)resetTouchState {
self.savedTapPoint = CGPointZero;
self.hasSavedTapPoint = NO;
if (self.isGameModeActive) {
[self.gameGestureManager resetState];
[self.gameGestureManager enableGestureForGameMode];
NSLog(@" 小游戏模式触摸状态已重置swipe 手势已重新启用");
} else {
NSLog(@" 非小游戏模式触摸状态已重置swipe 手势保持禁用");
}
}
// 🔧 combo
- (void)handleComboStateChanged:(NSNotification *)notification {
NSDictionary *userInfo = notification.userInfo;
NSString *uid = userInfo[@"uid"];
BOOL isCombo = [userInfo[@"isCombo"] boolValue];
NSLog(@"[Combo effect] 🔔 收到combo状态变化通知 - 用户: %@, 状态: %@", uid, isCombo ? @"YES" : @"NO");
// combo
if (isCombo) {
[self.giftAnimationManager setUserComboState:YES forUser:uid];
} else {
[self.giftAnimationManager clearUserComboState:uid];
}
}
// 🔧
- (BOOL)hasImportantAnimationPlaying {
// topContainer
for (UIView *subview in self.topContainer.subviews) {
if ([subview isKindOfClass:[BravoGiftWinningFlagView class]] ||
[subview isKindOfClass:[LuckyGiftWinningFlagView class]]) {
NSLog(@"🎯 检测到重要动画正在播放: %@", NSStringFromClass([subview class]));
return YES;
}
}
// middleContainer
for (UIView *subview in self.middleContainer.subviews) {
if ([subview isKindOfClass:[SVGAImageView class]] ||
[subview isKindOfClass:[VAPView class]] ||
[subview isKindOfClass:[PAGView class]]) {
NSLog(@"🎯 检测到礼物动画正在播放: %@", NSStringFromClass([subview class]));
return YES;
}
}
return NO;
}
// <EFBFBD><EFBFBD>
- (void)smartDestroy {
//
BOOL hasImportantAnimation = [self hasImportantAnimationPlaying];
if (hasImportantAnimation) {
NSLog(@"⚠️ 检测到重要动画正在播放,延迟销毁");
//
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(3.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
[self forceDestroy];
});
return;
}
[self forceDestroy];
}
- (void)forceDestroy {
NSLog(@"<22><> RoomAnimationView: 强制销毁");
// 广
[[NIMSDK sharedSDK].broadcastManager removeDelegate:self];
//
[NSObject cancelPreviousPerformRequestsWithTarget:self];
// POP
[self pop_removeAllAnimations];
//
if (_giftEffectTimer && !dispatch_source_testcancel(_giftEffectTimer)) {
dispatch_source_cancel(_giftEffectTimer);
_giftEffectTimer = nil;
}
//
if (_giftEffectsQueue) {
self.giftEffectsQueue = NULL;
}
// banner block
[self cleanupAllSubviews];
// BannerScheduler
if (self.bannerScheduler) {
NSLog(@"<22><> 清理 BannerScheduler");
[self.bannerScheduler clearQueue];
[self.bannerScheduler pause];
self.bannerScheduler = nil;
}
//
[self cleanupGestureRecognizers];
//
[self cleanupCacheManagers];
//
[self removeNotificationObservers];
NSLog(@"<22><> RoomAnimationView: 销毁完成");
}
@end

View File

@@ -29,6 +29,8 @@
@property (nonatomic,strong) UITapGestureRecognizer *tapEmptyRecognizer;
@property (nonatomic, strong) UIView *bottomSpace;
///
@property (nonatomic, assign) BOOL isAnimating;
@end
@@ -153,26 +155,42 @@
#pragma mark - Getters And Setters
- (void)setMessageInfo:(XPMessageInfoModel *)messageInfo {
//
//
if (_messageInfo &&
[messageInfo.content isEqualToAttributedString:_messageInfo.content] &&
[messageInfo.bubbleImageUrl isEqualToString:_messageInfo.bubbleImageUrl] &&
[messageInfo.boomImageUrl isEqualToString:_messageInfo.boomImageUrl]) {
[messageInfo.boomImageUrl isEqualToString:_messageInfo.boomImageUrl] &&
messageInfo.first == _messageInfo.first &&
messageInfo.second == _messageInfo.second) {
return;
}
_messageInfo = messageInfo;
if (messageInfo) {
// attributedTexthasBubble
BOOL hasBubble = ![NSString isEmpty:messageInfo.bubbleImageUrl];
if (self.contentLabel.hasBubble != hasBubble) {
self.contentLabel.hasBubble = hasBubble;
//
if (self.isAnimating) {
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
[self setMessageInfo:messageInfo];
});
return;
}
self.contentLabel.attributedText = messageInfo.content;
// 使 performWithoutAnimation
[UIView performWithoutAnimation:^{
// attributedTexthasBubble
BOOL hasBubble = ![NSString isEmpty:messageInfo.bubbleImageUrl];
if (self.contentLabel.hasBubble != hasBubble) {
self.contentLabel.hasBubble = hasBubble;
}
self.contentLabel.attributedText = messageInfo.content;
}];
//
if (self.isLeftBigImage && messageInfo.boomImageUrl) {
self.leftBigImageView.imageUrl = messageInfo.boomImageUrl;
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
self.leftBigImageView.imageUrl = messageInfo.boomImageUrl;
});
}
}
}
@@ -190,9 +208,13 @@
return;
}
[self.contentLabel mas_updateConstraints:^(MASConstraintMaker *make) {
make.width.mas_equalTo(size.width);
}];
// 使
[UIView animateWithDuration:0.2 delay:0 options:UIViewAnimationOptionCurveEaseOut animations:^{
[self.contentLabel mas_updateConstraints:^(MASConstraintMaker *make) {
make.width.mas_equalTo(size.width);
}];
[self layoutIfNeeded];
} completion:nil];
// [self.bubbleImageView mas_updateConstraints:^(MASConstraintMaker *make) {
// if (size.width + 32 >= kRoomMessageMaxWidth) {
@@ -206,13 +228,17 @@
if (hasBubble) {
@kWeakify(self);
[self.bubbleImageView loadImageWithUrl:self.messageInfo.bubbleImageUrl
completion:^(UIImage * _Nonnull image, NSURL * _Nonnull url) {
@kStrongify(self);
UIImage *image1 = [UIImage imageWithCGImage:image.CGImage scale:2.0 orientation:UIImageOrientationUp];
UIImage *cutImage = [image1 cropRightAndBottomPixels:2];
self.bubbleImageView.image = [self resizableImage:cutImage];
}];
//
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.05 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
[self.bubbleImageView loadImageWithUrl:self.messageInfo.bubbleImageUrl
completion:^(UIImage * _Nonnull image, NSURL * _Nonnull url) {
@kStrongify(self);
UIImage *image1 = [UIImage imageWithCGImage:image.CGImage scale:2.0 orientation:UIImageOrientationUp];
UIImage *cutImage = [image1 cropRightAndBottomPixels:2];
self.bubbleImageView.image = [self resizableImage:cutImage];
}];
});
[self.bubbleImageView mas_updateConstraints:^(MASConstraintMaker *make) {
make.edges.mas_equalTo(self.contentLabel).insets(UIEdgeInsetsMake(-10, -10, -10, -10));
}];

View File

@@ -133,8 +133,10 @@ NSString * const kRoomShowTopicKey = @"kRoomShowTopicKey";
/// tag datasource
- (void)updateAllDataSource:(NSArray *)datas {
if (!datas || datas.count == 0) {
self.datasource_chat = @[].mutableCopy;
self.datasource_gift = @[].mutableCopy;
//
[self.datasource_chat removeAllObjects];
[self.datasource_gift removeAllObjects];
//
datas = self.datasource;
}
@@ -152,6 +154,8 @@ NSString * const kRoomShowTopicKey = @"kRoomShowTopicKey";
case CustomMessageType_Treasure_Fairy:
[self.datasource_gift addObject:model];
break;
default:
break;
}
}
}
@@ -178,8 +182,6 @@ NSString * const kRoomShowTopicKey = @"kRoomShowTopicKey";
- (void)appendAndScrollToAtUser {
// 1. incomingMessages
if (self.incomingMessages.count < 1) {
NSInteger rows = self.datasource.count;
// 2. locationArray
if (self.locationArray.count == 0) {
[self scrollToBottomWithTipsHidden:YES];
@@ -193,15 +195,22 @@ NSString * const kRoomShowTopicKey = @"kRoomShowTopicKey";
return;
}
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:index inSection:0];
if (rows > indexPath.row) {
[self.messageTableView scrollToRowAtIndexPath:indexPath atScrollPosition:UITableViewScrollPositionBottom animated:YES];
if (rows == indexPath.row + 1) {
self.messageTipsBtn.hidden = YES;
self.isPending = NO;
}
} else {
// datasource
NSInteger convertedIndex = [self convertDataSourceIndexToCurrentDisplayIndex:index];
if (convertedIndex == NSNotFound) {
[self scrollToBottomWithTipsHidden:YES];
} else {
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:convertedIndex inSection:0];
NSInteger currentRows = [self getCurrentDataSourceCount];
if (currentRows > indexPath.row) {
[self.messageTableView scrollToRowAtIndexPath:indexPath atScrollPosition:UITableViewScrollPositionBottom animated:YES];
if (currentRows == indexPath.row + 1) {
self.messageTipsBtn.hidden = YES;
self.isPending = NO;
}
} else {
[self scrollToBottomWithTipsHidden:YES];
}
}
[self safelyRemoveLocationAtIndex:0];
@@ -217,8 +226,10 @@ NSString * const kRoomShowTopicKey = @"kRoomShowTopicKey";
needReloadData = YES; //
}
// 5.
NSMutableArray *indexPaths = @[].mutableCopy;
// 5.
NSInteger currentRows = [self getCurrentDataSourceCount];
// 6.
NSMutableArray *tempNewDatas = @[].mutableCopy;
for (id item in self.incomingMessages) {
XPMessageInfoModel *model = [self parseMessage:item];
@@ -226,18 +237,36 @@ NSString * const kRoomShowTopicKey = @"kRoomShowTopicKey";
[tempNewDatas addObject:model];
[self.datasource addObject:model];
[indexPaths addObject:[NSIndexPath indexPathForRow:self.datasource.count - 1 inSection:0]];
[self processAtMentionsForMessage:item];
}
[self updateAllDataSource:tempNewDatas];
[self.incomingMessages removeAllObjects];
// 使 reloadData使
// 7. UITableView
if (needReloadData) {
[self.messageTableView reloadData];
} else if (indexPaths.count > 0) {
[self.messageTableView insertRowsAtIndexPaths:indexPaths withRowAnimation:UITableViewRowAnimationNone];
} else if (tempNewDatas.count > 0) {
//
NSInteger expectedRows = [self getCurrentDataSourceCount];
if (expectedRows != [self.messageTableView numberOfRowsInSection:0]) {
[self.messageTableView reloadData];
} else {
// indexPath使
NSMutableArray *indexPaths = @[].mutableCopy;
NSInteger startIndex = currentRows;
if (startIndex >= 0 && startIndex <= [self.messageTableView numberOfRowsInSection:0]) {
for (NSInteger i = 0; i < tempNewDatas.count; i++) {
[indexPaths addObject:[NSIndexPath indexPathForRow:startIndex + i inSection:0]];
}
// 使
[UIView animateWithDuration:0.2 animations:^{
[self.messageTableView insertRowsAtIndexPaths:indexPaths withRowAnimation:UITableViewRowAnimationFade];
}];
} else {
[self.messageTableView reloadData];
}
}
}
// 6.
@@ -245,9 +274,11 @@ NSString * const kRoomShowTopicKey = @"kRoomShowTopicKey";
}
- (void)scrollToBottomWithTipsHidden:(BOOL)hidden {
NSInteger rows = self.datasource.count;
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:(rows - 1) inSection:0];
[self.messageTableView scrollToRowAtIndexPath:indexPath atScrollPosition:UITableViewScrollPositionBottom animated:YES];
NSInteger rows = [self getCurrentDataSourceCount];
if (rows > 0) {
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:(rows - 1) inSection:0];
[self.messageTableView scrollToRowAtIndexPath:indexPath atScrollPosition:UITableViewScrollPositionBottom animated:YES];
}
self.messageTipsBtn.hidden = hidden;
self.isPending = NO;
self.atCount = 0;
@@ -296,9 +327,32 @@ NSString * const kRoomShowTopicKey = @"kRoomShowTopicKey";
- (void)safelyRemoveMessages:(NSInteger)count {
if (self.datasource.count >= count) {
//
NSIndexSet *set = [NSIndexSet indexSetWithIndexesInRange:NSMakeRange(0, count)];
NSArray *removedMessages = [self.datasource objectsAtIndexes:set];
//
[self.datasource removeObjectsAtIndexes:set];
[self updateAllDataSource:nil];
//
for (XPMessageInfoModel *removedModel in removedMessages) {
switch (removedModel.first) {
case NIMMessageTypeText:
case CustomMessageType_Face:
[self.datasource_chat removeObject:removedModel];
break;
case CustomMessageType_Gift:
case CustomMessageType_RoomBoom:
case CustomMessageType_Candy_Tree:
case CustomMessageType_Super_Gift:
case CustomMessageType_AllMicroSend:
case CustomMessageType_Treasure_Fairy:
[self.datasource_gift removeObject:removedModel];
break;
default:
break;
}
}
// locationArray
NSMutableIndexSet *indexSet = [NSMutableIndexSet indexSet];
@@ -336,18 +390,98 @@ NSString * const kRoomShowTopicKey = @"kRoomShowTopicKey";
}
}
///
- (BOOL)isGiftMessage:(id)messageData {
if ([messageData isKindOfClass:[NIMMessage class]]) {
NIMMessage *message = (NIMMessage *)messageData;
if ([message.messageObject isKindOfClass:[NIMCustomObject class]]) {
NIMCustomObject *obj = (NIMCustomObject *)message.messageObject;
if (obj.attachment && [obj.attachment isKindOfClass:[AttachmentModel class]]) {
AttachmentModel *attachment = (AttachmentModel *)obj.attachment;
return (attachment.first == CustomMessageType_Gift ||
attachment.first == CustomMessageType_AllMicroSend ||
attachment.first == CustomMessageType_Super_Gift);
}
}
}
return NO;
}
- (NSInteger)getCurrentDataSourceCount {
NSInteger count = 0;
switch (self.displayType) {
case 1:
count = self.datasource.count;
break;
case 2:
count = self.datasource_chat.count;
break;
case 3:
count = self.datasource_gift.count;
break;
default:
count = self.datasource.count;
break;
}
//
return MAX(0, count);
}
- (NSInteger)convertDataSourceIndexToCurrentDisplayIndex:(NSInteger)dataSourceIndex {
if (self.displayType == 1) {
return dataSourceIndex;
}
if (dataSourceIndex >= self.datasource.count) {
return NSNotFound;
}
XPMessageInfoModel *targetModel = [self.datasource objectAtIndex:dataSourceIndex];
if (!targetModel) {
return NSNotFound;
}
NSArray *currentDataSource = nil;
switch (self.displayType) {
case 2:
currentDataSource = self.datasource_chat;
break;
case 3:
currentDataSource = self.datasource_gift;
break;
default:
return dataSourceIndex;
}
//
for (NSInteger i = 0; i < currentDataSource.count; i++) {
XPMessageInfoModel *model = [currentDataSource objectAtIndex:i];
if ([model isEqual:targetModel]) {
return i;
}
}
return NSNotFound;
}
- (void)scrollToFirstLocationOrBottom {
NSInteger rows = self.datasource.count;
if (self.locationArray.count == 0) {
[self scrollToBottomWithTipsHidden:YES];
return;
}
NSInteger index = [self safeGetIndexFromLocationArrayAt:0];
if (index == NSNotFound || index >= rows) {
if (index == NSNotFound) {
[self scrollToBottomWithTipsHidden:YES];
return;
}
// datasource
NSInteger convertedIndex = [self convertDataSourceIndexToCurrentDisplayIndex:index];
if (convertedIndex == NSNotFound) {
[self scrollToBottomWithTipsHidden:YES];
} else {
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:index inSection:0];
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:convertedIndex inSection:0];
[self.messageTableView scrollToRowAtIndexPath:indexPath atScrollPosition:UITableViewScrollPositionBottom animated:YES];
[self safelyRemoveLocationAtIndex:0];
}
@@ -459,7 +593,15 @@ NSString * const kRoomShowTopicKey = @"kRoomShowTopicKey";
self.messageTipsBtn.hidden = NO;
[self findAtMeNumber];
} else {
[self appendAndScrollToBottom];
// 使
if ([self isGiftMessage:messageData]) {
//
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.05 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
[self appendAndScrollToBottom];
});
} else {
[self appendAndScrollToBottom];
}
}
}
@@ -473,14 +615,15 @@ NSString * const kRoomShowTopicKey = @"kRoomShowTopicKey";
BOOL needReloadData = NO;
if (self.datasource.count > kRoomMessageMaxLength) {
NSIndexSet *set = [NSIndexSet indexSetWithIndexesInRange:NSMakeRange(0, kRoomMessageMaxLength/2)];
NSArray *needRemoveMsgArray = [self.datasource objectsAtIndexes:set];
[self.datasource removeObjectsInArray:needRemoveMsgArray];
NSInteger removedCount = kRoomMessageMaxLength / 2;
[self safelyRemoveMessages:removedCount];
needReloadData = YES; //
}
//
NSInteger currentRows = [self getCurrentDataSourceCount];
NSMutableArray *tempArray = @[].mutableCopy;
NSMutableArray *indexPaths = @[].mutableCopy;
@kWeakify(self);
[self.incomingMessages enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
@kStrongify(self);
@@ -493,7 +636,6 @@ NSString * const kRoomShowTopicKey = @"kRoomShowTopicKey";
if (model) {
[tempArray addObject:model];
[self.datasource addObject:model];
[indexPaths addObject:[NSIndexPath indexPathForRow:self.datasource.count - 1 inSection:0]];
}
}];
@@ -504,12 +646,35 @@ NSString * const kRoomShowTopicKey = @"kRoomShowTopicKey";
// 使 reloadData使
if (needReloadData) {
[self.messageTableView reloadData];
} else if (indexPaths.count > 0) {
[self.messageTableView insertRowsAtIndexPaths:indexPaths withRowAnimation:UITableViewRowAnimationNone];
} else if (tempArray.count > 0) {
//
NSInteger expectedRows = [self getCurrentDataSourceCount];
if (expectedRows != [self.messageTableView numberOfRowsInSection:0]) {
[self.messageTableView reloadData];
} else {
// indexPath使
NSMutableArray *indexPaths = @[].mutableCopy;
NSInteger startIndex = currentRows;
if (startIndex >= 0 && startIndex <= [self.messageTableView numberOfRowsInSection:0]) {
for (NSInteger i = 0; i < tempArray.count; i++) {
[indexPaths addObject:[NSIndexPath indexPathForRow:startIndex + i inSection:0]];
}
// 使
[UIView animateWithDuration:0.2 animations:^{
[self.messageTableView insertRowsAtIndexPaths:indexPaths withRowAnimation:UITableViewRowAnimationFade];
}];
} else {
[self.messageTableView reloadData];
}
}
}
//
[self scrollToBottom:NO];
//
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
[self scrollToBottom:NO];
});
}
///
@@ -540,7 +705,10 @@ NSString * const kRoomShowTopicKey = @"kRoomShowTopicKey";
[self.messageTableView scrollToRowAtIndexPath:ip atScrollPosition:UITableViewScrollPositionBottom animated:YES];
});
} else {
[self.messageTableView scrollToRowAtIndexPath:ip atScrollPosition:UITableViewScrollPositionBottom animated:NO];
// 使
[UIView animateWithDuration:0.3 delay:0 options:UIViewAnimationOptionCurveEaseOut animations:^{
[self.messageTableView scrollToRowAtIndexPath:ip atScrollPosition:UITableViewScrollPositionBottom animated:NO];
} completion:nil];
}
self.atCount = 0;
@@ -1340,21 +1508,23 @@ NSString * const kRoomShowTopicKey = @"kRoomShowTopicKey";
}
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
NSInteger count = 0;
switch (self.displayType) {
case 1:
return self.datasource.count;
count = self.datasource.count;
break;
case 2:
return self.datasource_chat.count;
count = self.datasource_chat.count;
break;
case 3:
return self.datasource_gift.count;
count = self.datasource_gift.count;
break;
default:
return self.datasource.count;
count = self.datasource.count;
break;
}
//
return MAX(0, count);
}
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {

View File

@@ -94,6 +94,10 @@ NS_ASSUME_NONNULL_BEGIN
- (NSDictionary * _Nonnull)getComboStateInfo;
- (void)printComboState;
// 🔧 新增:状态通知方法
- (void)setUserComboState:(BOOL)isCombo forUser:(NSString *)uid;
- (void)clearUserComboState:(NSString *)uid;
#pragma mark - 使用示例
/*

View File

@@ -11,6 +11,7 @@
#import <Foundation/Foundation.h>
#import <UIKit/UIKit.h>
#import <Bugly/Bugly.h>
#import "BuglyManager.h"
#import "Api+Gift.h"
@@ -790,19 +791,17 @@ NSString * const kBoomStateForceResetNotification = @"BoomStateForceResetNotific
self.errorMessage = [NSString stringWithFormat:@"服务器繁忙,请稍后重试 - %@", msg];
#else
self.errorMessage = @"Over Heat & try later";
dispatch_async(dispatch_get_global_queue(0, 0), ^{
NSMutableDictionary *logDic = [@{@"targetUids": allUIDs,
@"giftNum": self.giftNumPerTimes} mutableCopy];
[logDic addEntriesFromDictionary:dic];
[logDic setObject:[NSThread callStackSymbols] forKey:@"call stack symbols"];
[logDic setObject:msg forKey:@"error message"];
[logDic setObject:@"gift/sendV5" forKey:@"http method"];
[Bugly reportError:[NSError errorWithDomain:[NSString stringWithFormat:@"UID: %@,API: %@ 异常",
[AccountInfoStorage instance].getUid,
@"gift/sendV5"]
code:code
userInfo:logDic]];
});
// 使 BuglyManager
NSString *uid = [AccountInfoStorage instance].getUid ?: @"未知用户";
NSMutableDictionary *userInfo = [@{@"targetUids": allUIDs,
@"giftNum": self.giftNumPerTimes} mutableCopy];
[userInfo addEntriesFromDictionary:dic];
[userInfo setObject:msg forKey:@"error message"];
[[BuglyManager sharedManager] reportNetworkError:uid
api:@"gift/sendV5"
code:code
userInfo:userInfo];
#endif
//
} else if (code == 31005) {
@@ -959,4 +958,27 @@ NSString * const kBoomStateForceResetNotification = @"BoomStateForceResetNotific
self.timer ? @"运行中" : @"已停止");
}
// 🔧
- (void)setUserComboState:(BOOL)isCombo forUser:(NSString *)uid {
if (!uid || uid.length == 0) {
NSLog(@"[Combo effect] ⚠️ 用户ID为空无法设置combo状态");
return;
}
NSLog(@"[Combo effect] 🔔 通知动画管理器设置用户 %@ 的combo状态为: %@", uid, isCombo ? @"YES" : @"NO");
//
[[NSNotificationCenter defaultCenter] postNotificationName:@"GiftComboStateChanged"
object:nil
userInfo:@{
@"uid": uid,
@"isCombo": @(isCombo)
}];
}
- (void)clearUserComboState:(NSString *)uid {
[self setUserComboState:NO forUser:uid];
}
@end

View File

@@ -20,6 +20,7 @@
///P
#import "XPGiftProtocol.h"
#import <Bugly/Bugly.h>
#import "BuglyManager.h"
@interface XPGiftPresenter ()
///

View File

@@ -74,7 +74,11 @@
- (instancetype)init {
if (self = [super init]) {
self.feedbackGenerator = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleHeavy];
// 🔥
if (@available(iOS 10.0, *)) {
self.feedbackGenerator = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleHeavy];
[self.feedbackGenerator prepare]; // 🔥 prepare
}
self.updateGoldQueue = @[].mutableCopy;
@@ -299,10 +303,26 @@
NSLog(@"[Combo effect] ⚠️ 点击间隔过短,忽略此次点击");
return;
}
// 🔥
if (@available(iOS 10.0, *)) {
if (self.feedbackGenerator) {
@try {
[self.feedbackGenerator impactOccurred];
NSLog(@"[Combo effect] 📳 震动反馈已触发");
} @catch (NSException *exception) {
NSLog(@"[Combo effect] ⚠️ 震动反馈失败: %@", exception.reason);
}
} else {
NSLog(@"[Combo effect] ⚠️ 震动反馈生成器未初始化");
}
} else {
NSLog(@"[Combo effect] ⚠️ 设备不支持震动反馈 (iOS < 10.0)");
}
#if RELEASE
isHandlingTap = YES;
#endif
[self.feedbackGenerator impactOccurred];
NSLog(@"[Combo effect] 👆 连击面板被点击,发送礼物");
@@ -330,6 +350,16 @@
[[GiftComboManager sharedManager] clear];
}
// 🔥
- (void)prepareFeedbackGenerator {
if (@available(iOS 10.0, *)) {
if (self.feedbackGenerator) {
[self.feedbackGenerator prepare];
NSLog(@"[Combo effect] 📳 震动反馈已重新准备");
}
}
}
// SVGAPlayerDelegate:
- (void)svgaPlayerDidFinishedAnimation:(SVGAPlayer *)player {
[self.playImageView stepToPercentage:0 andPlay:NO];

View File

@@ -161,6 +161,13 @@
self.isAnimating = NO;
self.shouldStopAnimation = NO;
//
if (self.source.count < 2) {
NSLog(@"⚠️ PIGiftBravoGiftBroadcastView: 数据源不足 (%lu个),动画可能过快", (unsigned long)self.source.count);
} else {
NSLog(@"✅ PIGiftBravoGiftBroadcastView: 数据源正常 (%lu个)", (unsigned long)self.source.count);
}
@kWeakify(self);
[self.source enumerateObjectsUsingBlock:^(BravoGiftTabInfomationModel * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
@kStrongify(self);
@@ -176,10 +183,13 @@
- (void)startLoop {
if (self.isAnimating || self.labels.count == 0 || self.shouldStopAnimation) {
NSLog(@"🚫 PIGiftBravoGiftBroadcastView: 动画启动被阻止 - isAnimating:%d, labelsCount:%lu, shouldStop:%d",
self.isAnimating, (unsigned long)self.labels.count, self.shouldStopAnimation);
return;
}
self.isAnimating = YES;
NSLog(@"🎬 PIGiftBravoGiftBroadcastView: 开始动画循环");
[self playCurrentAnimation];
}
@@ -187,21 +197,33 @@
- (void)startAnimation {
if (self.shouldStopAnimation) {
self.shouldStopAnimation = NO;
NSLog(@"🔄 PIGiftBravoGiftBroadcastView: 重置停止标志,重新开始动画");
}
if (self.labels.count > 0) {
[self startLoop];
} else {
NSLog(@"⚠️ PIGiftBravoGiftBroadcastView: 无法开始动画labels为空");
}
}
//
- (void)stopAnimation {
NSLog(@"⏹️ PIGiftBravoGiftBroadcastView: 停止动画");
[self endloop];
}
- (void)playCurrentAnimation {
//
if (self.shouldStopAnimation) {
self.isAnimating = NO;
NSLog(@"🚫 PIGiftBravoGiftBroadcastView: 动画被停止标志阻止");
return;
}
//
if (self.container.subviews.count > 0) {
NSLog(@"⚠️ PIGiftBravoGiftBroadcastView: 容器中已有视图,跳过当前动画");
return;
}
@@ -209,9 +231,12 @@
PIGiftBravoGiftBroadcastItemView *item = [self.labels xpSafeObjectAtIndex:self.index];
if (!item) {
self.isAnimating = NO;
NSLog(@"❌ PIGiftBravoGiftBroadcastView: 无法获取当前索引(%ld)的视图", (long)self.index);
return;
}
NSLog(@"🎭 PIGiftBravoGiftBroadcastView: 播放第%ld个动画项", (long)self.index);
//
item.frame = CGRectMake(KScreenWidth, 2, KScreenWidth - 25 - 42, 26);
//
@@ -226,11 +251,12 @@
if (!finished || self.shouldStopAnimation) {
[item removeFromSuperview];
self.isAnimating = NO;
NSLog(@"❌ PIGiftBravoGiftBroadcastView: 入场动画被中断");
return;
}
// 2
[UIView animateWithDuration:0.5 delay:2.0 options:UIViewAnimationOptionCurveEaseIn animations:^{
// 23
[UIView animateWithDuration:0.5 delay:3.0 options:UIViewAnimationOptionCurveEaseIn animations:^{
item.frame = CGRectMake(-KScreenWidth, 2, KScreenWidth - 25 - 42, 26);
} completion:^(BOOL finished) {
@kStrongify(self);
@@ -239,6 +265,7 @@
if (self.shouldStopAnimation) {
self.isAnimating = NO;
NSLog(@"⏹️ PIGiftBravoGiftBroadcastView: 出场动画被停止");
return;
}
@@ -246,10 +273,12 @@
self.index += 1;
if (self.index >= self.labels.count) {
self.index = 0;
NSLog(@"🔄 PIGiftBravoGiftBroadcastView: 动画循环重置到开始");
}
// 使
self.animationTimer = [NSTimer scheduledTimerWithTimeInterval:0.1 target:self selector:@selector(scheduleNextAnimation) userInfo:nil repeats:NO];
// 使0.10.5
self.animationTimer = [NSTimer scheduledTimerWithTimeInterval:0.5 target:self selector:@selector(scheduleNextAnimation) userInfo:nil repeats:NO];
NSLog(@"⏰ PIGiftBravoGiftBroadcastView: 安排下一个动画延迟0.5秒");
}];
}];
}
@@ -260,9 +289,11 @@
if (self.shouldStopAnimation) {
self.isAnimating = NO;
NSLog(@"🚫 PIGiftBravoGiftBroadcastView: 定时器回调被停止标志阻止");
return;
}
NSLog(@"▶️ PIGiftBravoGiftBroadcastView: 定时器触发下一个动画");
[self playCurrentAnimation];
}
@@ -271,6 +302,8 @@
self.shouldStopAnimation = YES;
self.isAnimating = NO;
NSLog(@"🛑 PIGiftBravoGiftBroadcastView: 结束动画循环");
//
if (self.animationTimer) {
[self.animationTimer invalidate];

View File

@@ -161,5 +161,30 @@ UIKIT_EXTERN NSString * const kRoomRoomLittleGameMiniStageNotificationKey;
return _scrollView;
}
#pragma mark -
//
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
NSLog(@"🎯 LittleGameScrollStageView: touchesBegan 被调用");
[super touchesBegan:touches withEvent:event];
}
//
- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
NSLog(@"🎯 LittleGameScrollStageView: touchesMoved 被调用");
[super touchesMoved:touches withEvent:event];
}
//
- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
NSLog(@"🎯 LittleGameScrollStageView: touchesEnded 被调用");
[super touchesEnded:touches withEvent:event];
}
//
- (void)touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
NSLog(@"🎯 LittleGameScrollStageView: touchesCancelled 被调用");
[super touchesCancelled:touches withEvent:event];
}
@end

View File

@@ -0,0 +1,60 @@
//
// StageViewManager.h
// YUMI
//
// Created by YUMI on 2024/12/19.
//
#import <Foundation/Foundation.h>
#import "RoomInfoModel.h"
#import "RoomHostDelegate.h"
@class StageView;
// 使用 RoomInfoModel 中已有的 RoomType 定义
// 避免重复定义,保持代码一致性
NS_ASSUME_NONNULL_BEGIN
@interface StageViewManager : NSObject
@property (nonatomic, strong, readonly) StageView *currentStageView;
@property (nonatomic,
assign,
readonly) RoomType currentRoomType;
/**
更新 stageView 到指定房间类型
@param roomType 房间类型
@param roomInfo 房间信息
@param container 容器视图
@param delegate stageView 的代理对象
@return 是否更新成功
*/
- (BOOL)updateStageViewForRoomType:(RoomType)roomType
roomInfo:(RoomInfoModel *)roomInfo
container:(UIView *)container
delegate:(id<RoomHostDelegate>)delegate;
/**
通知 stageView 更新
@param roomInfo 房间信息
*/
- (void)notifyStageViewUpdate:(RoomInfoModel *)roomInfo;
/**
清理资源
*/
- (void)cleanup;
/**
检查是否需要切换 stageView 类型
@param newType 新的房间类型
@return 是否需要切换
*/
- (BOOL)shouldChangeStageViewType:(RoomType)newType;
@end
NS_ASSUME_NONNULL_END

View File

@@ -0,0 +1,145 @@
//
// StageViewManager.m
// YUMI
//
// Created by YUMI on 2024/12/19.
//
#import "StageViewManager.h"
#import "RoomInfoModel.h"
#import "SocialStageView.h"
#import "TenMicStageView.h"
#import "FifteenMicStageView.h"
#import "NineteenMicStageView.h"
#import "TwentyMicStageView.h"
#import "DatingStageView.h"
#import "AnchorStageView.h"
#import "AnchorPkStageView.h"
#import "LittleGameStageView.h"
#import "LittleGameScrollStageView.h"
@interface StageViewManager ()
@property (nonatomic, strong) StageView *currentStageView;
@property (nonatomic, assign) RoomType currentRoomType;
@property (nonatomic, weak) UIView *container;
@end
@implementation StageViewManager
- (instancetype)init {
self = [super init];
if (self) {
_currentRoomType = RoomType_Game; // RoomType_Game
}
return self;
}
- (BOOL)updateStageViewForRoomType:(RoomType)roomType
roomInfo:(id)roomInfo
container:(UIView *)container
delegate:(id<RoomHostDelegate>)delegate {
// stageView
if ([self shouldChangeStageViewType:roomType]) {
NSLog(@"🔄 StageViewManager: 切换 stageView 类型 | 从 %ld 到 %ld",
(long)self.currentRoomType, (long)roomType);
// stageView
[self cleanup];
// stageView
[self createStageViewForType:roomType withDelegate:delegate];
self.currentRoomType = roomType;
self.container = container;
}
// stageView
// if (self.currentStageView && !self.currentStageView.superview && container) {
// [container insertSubview:self.currentStageView atIndex:0];
// NSLog(@"✅ StageViewManager: stageView 已添加到容器");
// }
// stageView
return self.currentStageView != nil;
}
- (void)notifyStageViewUpdate:(id)roomInfo {
if (self.currentStageView && [self.currentStageView respondsToSelector:@selector(onRoomUpdate)]) {
NSLog(@"🔄 StageViewManager: 通知 stageView 更新 | type=%ld", (long)self.currentRoomType);
[self.currentStageView onRoomUpdate];
}
}
- (void)cleanup {
if (self.currentStageView) {
[self.currentStageView removeFromSuperview];
self.currentStageView = nil;
NSLog(@"🧹 StageViewManager: 清理 stageView");
}
}
- (BOOL)shouldChangeStageViewType:(RoomType)newType {
return self.currentRoomType != newType || !self.currentStageView;
}
#pragma mark - Private Methods
- (void)createStageViewForType:(NSInteger)roomType withDelegate:(id<RoomHostDelegate>)delegate {
// stageView
// stageView delegate
switch (roomType) {
case RoomType_Anchor: { // RoomType_Anchor
// PK
// roomInfo 使 AnchorStageView
if ([delegate respondsToSelector:@selector(getRoomInfo)]) {
RoomInfoModel *roomInfo = [delegate getRoomInfo];
if (roomInfo.roomModeType == RoomModeType_Open_AcrossRoomPK_mode) {
self.currentStageView = [[AnchorPKStageView alloc] initWithDelegate:delegate];
} else {
self.currentStageView = [[AnchorStageView alloc] initWithDelegate:delegate];
}
} else {
self.currentStageView = [[AnchorStageView alloc] initWithDelegate:delegate];
}
break;
}
case RoomType_MiniGame: { // RoomType_MiniGame
self.currentStageView = [[LittleGameScrollStageView alloc] initWithDelegate:delegate];
break;
}
case RoomType_20Mic: { // RoomType_20Mic
self.currentStageView = [[TwentyMicStageView alloc] initWithDelegate:delegate];
break;
}
case RoomType_19Mic: { // RoomType_19Mic
self.currentStageView = [[NineteenMicStageView alloc] initWithDelegate:delegate];
break;
}
case RoomType_15Mic: { // RoomType_15Mic
self.currentStageView = [[FifteenMicStageView alloc] initWithDelegate:delegate];
break;
}
case RoomType_10Mic: { // RoomType_10Mic
self.currentStageView = [[TenMicStageView alloc] initWithDelegate:delegate];
break;
}
case RoomType_Game: // RoomType_Game
default: {
self.currentStageView = [[SocialStageView alloc] initWithDelegate:delegate];
break;
}
}
if (self.currentStageView) {
NSLog(@"✅ StageViewManager: 创建 stageView 成功 | type=%ld | class=%@",
(long)roomType, NSStringFromClass([self.currentStageView class]));
} else {
NSLog(@"❌ StageViewManager: 创建 stageView 失败 | type=%ld", (long)roomType);
}
}
@end

View File

@@ -9,7 +9,7 @@
#import "XPMessageRemoteExtModel.h"
// 公共房间消息转发通知名称
UIKIT_EXTERN NSString * const kMessageFromPublicRoomWithAttachmentNotification;
UIKIT_EXTERN NSString * _Nullable const kMessageFromPublicRoomWithAttachmentNotification;
NS_ASSUME_NONNULL_BEGIN

View File

@@ -76,6 +76,7 @@
#import "LittleGameStageView.h"
#import "LittleGameScrollStageView.h"
#import "XPRoomLittleGameContainerView.h"
#import "StageViewManager.h"
#import "PIRoomEnterRedPacketView.h"
#import "XPIAPRechargeViewController.h"
#import "XPCandyTreeInsufficientBalanceView.h"
@@ -127,6 +128,7 @@ XPCandyTreeInsufficientBalanceViewDelegate>
@property (nonatomic,strong) RoomHeaderView *roomHeaderView;
///
@property (nonatomic,strong) StageView *stageView;
@property (nonatomic,strong) StageViewManager *stageViewManager;
@property (nonatomic,strong) TenMicStageView *tenMicStageView;
@property (nonatomic,strong) FifteenMicStageView *fifteenMicStageView;
@property (nonatomic,strong) TwentyMicStageView *twentyMicStageView;
@@ -195,6 +197,9 @@ XPCandyTreeInsufficientBalanceViewDelegate>
/// 10
@property(nonatomic,strong) NSTimer *upMicAskTimer;
/// 🔧 block
@property(nonatomic,strong) id<NSObject> exchangeRoomAnimationViewObserver;
@end
@implementation XPRoomViewController
@@ -310,6 +315,12 @@ XPCandyTreeInsufficientBalanceViewDelegate>
[[RoomBoomManager sharedManager] removeEventListenerForTarget:self];
// 🔧 block
if (self.exchangeRoomAnimationViewObserver) {
[[NSNotificationCenter defaultCenter] removeObserver:self.exchangeRoomAnimationViewObserver];
self.exchangeRoomAnimationViewObserver = nil;
}
[[NSNotificationCenter defaultCenter] removeObserver:self];
// 🔧 RoomAnimationView
@@ -366,6 +377,7 @@ XPCandyTreeInsufficientBalanceViewDelegate>
[self handleGiftComboCallBack];
[self setupStageViewManager];
}
//- (void)test {
@@ -451,7 +463,8 @@ XPCandyTreeInsufficientBalanceViewDelegate>
object:nil];
@kWeakify(self);
[[NSNotificationCenter defaultCenter] addObserverForName:@"kExchangeRoomAnimationViewAndGameViewIndex"
// 🔧 block
self.exchangeRoomAnimationViewObserver = [[NSNotificationCenter defaultCenter] addObserverForName:@"kExchangeRoomAnimationViewAndGameViewIndex"
object:nil
queue:NSOperationQueue.mainQueue
usingBlock:^(NSNotification * _Nonnull notification) {
@@ -591,7 +604,6 @@ XPCandyTreeInsufficientBalanceViewDelegate>
self.view.backgroundColor = [UIColor darkGrayColor];
[self.view addSubview:self.backContainerView];
[self.view addSubview:self.littleGameView];
[self.view addSubview:self.stageView];
[self.view addSubview:self.messageContainerView];
[self.view addSubview:self.quickMessageContainerView];
@@ -647,14 +659,8 @@ XPCandyTreeInsufficientBalanceViewDelegate>
make.height.mas_equalTo(kNavigationHeight);
}];
[self.stageView mas_makeConstraints:^(MASConstraintMaker *make) {
make.leading.trailing.mas_equalTo(self.view);
make.top.mas_equalTo(self.roomHeaderView.mas_bottom);
make.height.mas_equalTo(self.stageView.hightForStageView);
}];
[self.messageContainerView mas_makeConstraints:^(MASConstraintMaker *make) {
make.top.equalTo(self.stageView.mas_bottom);
make.top.equalTo(self.roomHeaderView.mas_bottom);
make.bottom.equalTo(self.quickMessageContainerView.mas_top).offset(-5);
make.leading.equalTo(self.view);
make.trailing.equalTo(self.sideMenu.mas_leading).offset(-10);
@@ -883,15 +889,42 @@ XPCandyTreeInsufficientBalanceViewDelegate>
}
- (BOOL)updateStageView {
Class stageViewClass = [self stageViewClassForRoomInfo:self.roomInfo];
if (stageViewClass) { //&& ![self.stageView isKindOfClass:stageViewClass]) {
[self.stageView removeFromSuperview];
self.stageView = nil;
self.stageView = [[stageViewClass alloc] initWithDelegate:self];
self.stageView.alpha = 0;
return YES;
NSLog(@"🔄 updateStageView: 开始更新 stageView当前 alpha = %.2f", self.stageView.alpha);
// 使 StageViewManager stageView
if (self.stageViewManager) {
NSLog(@"🔧 updateStageView: 使用 StageViewManager");
BOOL success = [self.stageViewManager updateStageViewForRoomType:self.roomInfo.type
roomInfo:self.roomInfo
container:self.view
delegate:self];
if (success) {
self.stageView = self.stageViewManager.currentStageView;
if (self.stageView) {
NSLog(@"🔧 updateStageView: StageViewManager 设置 alpha = 0 (之前 = %.2f)", self.stageView.alpha);
self.stageView.alpha = 0;
NSLog(@"✅ StageViewManager 成功更新 stageView: %@", NSStringFromClass([self.stageView class]));
return YES;
}
}
NSLog(@"⚠️ StageViewManager 更新失败,降级到原有逻辑");
}
//
// NSLog(@"🔧 updateStageView: 使用降级逻辑");
// Class stageViewClass = [self stageViewClassForRoomInfo:self.roomInfo];
// if (stageViewClass) { //&& ![self.stageView isKindOfClass:stageViewClass]) {
// NSLog(@"🔧 updateStageView: 创建新的 stageView: %@", NSStringFromClass(stageViewClass));
// [self.stageView removeFromSuperview];
// self.stageView = nil;
// self.stageView = [[stageViewClass alloc] initWithDelegate:self];
// NSLog(@"🔧 updateStageView: 降级逻辑设置 alpha = 0 (之前 = %.2f)", self.stageView.alpha);
// self.stageView.alpha = 0;
// NSLog(@"✅ 降级逻辑成功更新 stageView: %@", NSStringFromClass([self.stageView class]));
// return YES;
// }
NSLog(@"❌ 所有 stageView 更新方法都失败了");
return NO;
}
@@ -928,12 +961,22 @@ XPCandyTreeInsufficientBalanceViewDelegate>
}
- (void)changeStageViewOnRoomUpdate {
NSLog(@"🔄 changeStageViewOnRoomUpdate: 开始更新 stageView");
if (![self updateStageView]) {
NSLog(@"❌ changeStageViewOnRoomUpdate: updateStageView 失败");
return;
}
NSLog(@"✅ changeStageViewOnRoomUpdate: updateStageView 成功,当前 alpha = %.2f", self.stageView.alpha);
// 🔧
[[NSNotificationCenter defaultCenter] postNotificationName:@"RoomTypeChanged"
object:nil
userInfo:@{@"roomType": @(self.roomInfo.type)}];
if (!self.stageView.superview) {
NSLog(@"🔧 changeStageViewOnRoomUpdate: 添加 stageView 到视图层级");
[self.view insertSubview:self.stageView
belowSubview:self.roomHeaderView];
@@ -955,7 +998,12 @@ XPCandyTreeInsufficientBalanceViewDelegate>
}
}
NSLog(@"🔧 changeStageViewOnRoomUpdate: 设置 stageView.alpha = 1 (之前 = %.2f)", self.stageView.alpha);
self.stageView.alpha = 1;
NSLog(@"✅ changeStageViewOnRoomUpdate: stageView.alpha 已设置为 1");
//
[self debugStageViewStatus];
[self addExitGameButton];
@@ -1435,11 +1483,15 @@ XPCandyTreeInsufficientBalanceViewDelegate>
}
- (void)handleRoomWithoutPasswordAfterInitRoom {
NSLog(@"🔄 handleRoomWithoutPasswordAfterInitRoom: 开始处理无密码房间");
self.roomInfo.datingState = (self.roomInfo.roomModeType == RoomModeType_Open_Blind) ?
RoomDatingStateChangeType_Open :
RoomDatingStateChangeType_Normal;
NSLog(@"🔧 handleRoomWithoutPasswordAfterInitRoom: 调用 changeStageViewOnRoomUpdate");
[self changeStageViewOnRoomUpdate];
NSLog(@"✅ handleRoomWithoutPasswordAfterInitRoom: changeStageViewOnRoomUpdate 完成");
[self.roomHeaderView onRoomEntered];
[self.sideMenu onRoomEntered];
@@ -1917,30 +1969,47 @@ XPCandyTreeInsufficientBalanceViewDelegate>
}
if (![message.session.sessionId isEqualToString:@(self.roomInfo.roomId).stringValue]) {
NSLog(@"[Recv] ⛔️ 过滤:房间不匹配 | msg.sid=%@ | curRoomId=%@",
message.session.sessionId, @(self.roomInfo.roomId).stringValue);
continue;
// if (message.messageType == NIMMessageTypeCustom) {
// NIMCustomObject *obj = (NIMCustomObject *)message.messageObject;
// if ([obj.attachment isKindOfClass:[AttachmentModel class]]) {
// AttachmentModel *att = (AttachmentModel *)obj.attachment;
// if (!att.isFromPublic) {
// NSLog(@"[Recv] ⛔️ 过滤:房间不匹配 | msg.sid=%@ | curRoomId=%@",
// message.session.sessionId, @(self.roomInfo.roomId).stringValue);
// continue;
// }
// }
// }else {
NSLog(@"[Recv] ⛔️ 过滤:房间不匹配 | msg.sid=%@ | curRoomId=%@",
message.session.sessionId, @(self.roomInfo.roomId).stringValue);
continue;
// }
}
NSLog(@"[Recv] --- Message Raw Attach Content: %@, %@, %ld", @(message.senderClientType), message.rawAttachContent, (long)message.messageType);
if ([message.rawAttachContent containsString:@"\"allRoomMsg\":1"]) {
NSLog(@"[Recv] --- 拦截旧的全房间消息");
continue;
}
if (message.messageType == NIMMessageTypeNotification) {
[self handleNIMNotificationTypeMessage:message];
} else if (message.messageType == NIMMessageTypeCustom) {
// first/second/size3
//#if DEBUG
// if ([message.messageObject isKindOfClass:[NIMCustomObject class]]) {
// NIMCustomObject *obj = (NIMCustomObject *)message.messageObject;
// if ([obj.attachment isKindOfClass:[AttachmentModel class]]) {
// AttachmentModel *att = (AttachmentModel *)obj.attachment;
// NSData *payloadJSON = nil;
// @try { payloadJSON = [NSJSONSerialization dataWithJSONObject:att.data ?: @{} options:0 error:nil]; } @catch (__unused NSException *e) {}
// NSLog(@"[Recv] 🎯 自定义消息 | first=%ld second=%ld | payload=%lub | sid=%@ | ts=%.3f",
// (long)att.first, (long)att.second, (unsigned long)payloadJSON.length,
// message.session.sessionId, [[NSDate date] timeIntervalSince1970]);
// }
// }
//#endif
#if DEBUG
if ([message.messageObject isKindOfClass:[NIMCustomObject class]]) {
NIMCustomObject *obj = (NIMCustomObject *)message.messageObject;
if ([obj.attachment isKindOfClass:[AttachmentModel class]]) {
AttachmentModel *att = (AttachmentModel *)obj.attachment;
NSData *payloadJSON = nil;
@try { payloadJSON = [NSJSONSerialization dataWithJSONObject:att.data ?: @{} options:0 error:nil]; } @catch (__unused NSException *e) {}
NSLog(@"[Recv] 🎯 自定义消息 | first=%ld second=%ld | payload=%lub | sid=%@ | ts=%.3f",
(long)att.first, (long)att.second, (unsigned long)payloadJSON.length,
message.session.sessionId, [[NSDate date] timeIntervalSince1970]);
}
}
#endif
[self handleNimCustomTypeMessage:message];
} else if(message.messageType == NIMMessageTypeText) {
[self.messageContainerView handleNIMTextMessage:message];
@@ -2064,6 +2133,7 @@ XPCandyTreeInsufficientBalanceViewDelegate>
//->->)
newRoomInfo.hadChangeRoomType = self.roomInfo.type != newRoomInfo.type;
BOOL anchorToOther = newRoomInfo.type != RoomType_Anchor && self.roomInfo.type == RoomType_Anchor;//
RoomType currentType = self.roomInfo.type;
self.roomInfo = newRoomInfo;
[self.backContainerView onRoomUpdate];
@@ -3118,9 +3188,16 @@ XPCandyTreeInsufficientBalanceViewDelegate>
}
- (StageView *)stageView {
// 🔧 StageViewManager stageView
if (self.stageViewManager && self.stageViewManager.currentStageView) {
return self.stageViewManager.currentStageView;
}
// 🔧 StageViewManager SocialStageView
if (!_stageView) {
_stageView = [[SocialStageView alloc] initWithDelegate:self];
_stageView.alpha = 0;
NSLog(@"⚠️ 使用降级 stageView: SocialStageView");
}
return _stageView;
}
@@ -3219,9 +3296,59 @@ XPCandyTreeInsufficientBalanceViewDelegate>
}
// 使
[self.messageContainerView handleNIMCustomMessage:message];
// [self.messageContainerView handleNIMCustomMessage:message];
// [self.animationView handleNIMCustomMessage:message];
// [self handleNimCustomTypeMessage:message];
// [self onRecvMessages:@[message]];
NSLog(@"XPRoomViewController: 处理公共房间转发的106类型消息");
switch (message.messageType) {
case NIMMessageTypeNotification:
[self handleNIMNotificationTypeMessage:message];
break;
case NIMMessageTypeCustom:
[self handleNimCustomTypeMessage:message];
break;
case NIMMessageTypeText:
[self.messageContainerView handleNIMTextMessage:message];
[self.littleGameView handleNIMTextMessage:message];
break;
case NIMMessageTypeTip:
[self.messageContainerView handleNIMTextMessage:message];
break;
case NIMMessageTypeImage:
[self.messageContainerView handleNIMImageMessage:message];
break;
default:
break;
}
}
// StageViewManager
- (StageViewManager *)stageViewManager {
if (!_stageViewManager) {
_stageViewManager = [[StageViewManager alloc] init];
NSLog(@"✅ StageViewManager 懒加载初始化成功");
}
return _stageViewManager;
}
// StageViewManager
- (void)setupStageViewManager {
//
[self stageViewManager];
NSLog(@"✅ StageViewManager 设置完成");
}
// stageView
- (void)debugStageViewStatus {
NSLog(@"🔍 DEBUG: stageView 状态检查");
NSLog(@" - stageView: %@", self.stageView);
NSLog(@" - stageView.alpha: %.2f", self.stageView.alpha);
NSLog(@" - stageView.hidden: %@", self.stageView.hidden ? @"YES" : @"NO");
NSLog(@" - stageView.superview: %@", self.stageView.superview);
NSLog(@" - stageView.frame: %@", NSStringFromCGRect(self.stageView.frame));
NSLog(@" - stageView.class: %@", NSStringFromClass([self.stageView class]));
}
@end

View File

@@ -140,6 +140,9 @@ UIKIT_EXTERN NSString *kTabShowAnchorCardKey;
[[NIMSDK sharedSDK].chatManager removeDelegate:self];
[[NIMSDK sharedSDK].systemNotificationManager removeDelegate:self];
[[NIMSDK sharedSDK].broadcastManager removeDelegate:self];
// 🔧 RoomBoomManager
[[RoomBoomManager sharedManager] removeEventListenerForTarget:self];
}
- (void)viewDidLoad {
@@ -186,10 +189,13 @@ UIKIT_EXTERN NSString *kTabShowAnchorCardKey;
[[RoomBoomManager sharedManager] registerBoomBanner:^(id _Nonnull sth) {
dispatch_async(dispatch_get_main_queue(), ^{
[RoomBoomBannerAnimation display:kWindow
with:sth
tapToRoom:YES
complete:^{}];
// 🔧
if ([XPSkillCardPlayerManager shareInstance].isInRoom == YES) {
[RoomBoomBannerAnimation display:kWindow
with:sth
tapToRoom:YES
complete:^{}];
}
});
} target:self];

View File

@@ -14,6 +14,7 @@
#import "MSParamsDecode.h"
#import "NSData+GZIP.h"
#import <Bugly/Bugly.h>
#import "BuglyManager.h"
@implementation HttpRequestHelper
@@ -93,7 +94,7 @@
editParam = [MSParamsDecode msDecodeParams:editParam];
params = [self configBaseParmars:editParam];
#if DEBUG
#if 0
// URL
NSString *baseUrl = [HttpRequestHelper getHostUrl];
NSString *fullUrl = [NSString stringWithFormat:@"%@/%@", baseUrl, method];
@@ -117,7 +118,7 @@
@kWeakify(self);
[manager GET:method parameters:params headers:nil progress:nil success:^(NSURLSessionDataTask * _Nonnull task, id _Nullable responseObject) {
BaseModel *baseModel = [BaseModel modelWithDictionary:responseObject];
#if DEBUG
#if 0
NSLog(@"%@ - \n%@\n", method, [baseModel toJSONString]);
#else
#endif
@@ -337,7 +338,7 @@ constructingBodyWithBlock:^(id<AFMultipartFormData> _Nonnull formData) {
params = [MSParamsDecode msDecodeParams:[params mutableCopy] ];
params = [self configBaseParmars:params];
AFHTTPSessionManager *manager = [HttpRequestHelper requestManager];
#if DEBUG
#if 0
// URL
NSString *baseUrl = [HttpRequestHelper getHostUrl];
NSString *fullUrl = [NSString stringWithFormat:@"%@/%@", baseUrl, method];
@@ -362,7 +363,7 @@ constructingBodyWithBlock:^(id<AFMultipartFormData> _Nonnull formData) {
@kWeakify(self);
[manager DELETE:method parameters:params headers:nil success:^(NSURLSessionDataTask * _Nonnull task, id _Nullable responseObject) {
BaseModel *baseModel = [BaseModel modelWithDictionary:responseObject];
#if DEBUG
#if 0
NSLog(@"\n%@\n", [baseModel toJSONString]);
#else
#endif
@@ -409,18 +410,16 @@ constructingBodyWithBlock:^(id<AFMultipartFormData> _Nonnull formData) {
completion(nil, resCode, message);
}
if (resCode > 500 && resCode < 600) {
dispatch_async(dispatch_get_global_queue(0, 0), ^{
NSMutableDictionary *logDic = [params mutableCopy];
[logDic setObject:[NSThread callStackSymbols] forKey:@"call stack symbols"];
[logDic setObject:message forKey:@"error message"];
[logDic setObject:@(method) forKey:@"http method"];
// BLYLog(BuglyLogLevelWarn, @"%@", [logDic toJSONString]);
[Bugly reportError:[NSError errorWithDomain:[NSString stringWithFormat:@"UID: %@,API: %@ 异常",
[AccountInfoStorage instance].getUid,
path]
code:resCode
userInfo:logDic]];
});
// 使 BuglyManager
NSString *uid = [AccountInfoStorage instance].getUid ?: @"未知用户";
NSMutableDictionary *userInfo = [params mutableCopy];
[userInfo setObject:message forKey:@"error message"];
[userInfo setObject:@(method) forKey:@"http method"];
[[BuglyManager sharedManager] reportNetworkError:uid
api:path
code:resCode
userInfo:userInfo];
}
}];
}

View File

@@ -13,6 +13,7 @@
#import "BaseNavigationController.h"
#import "FirstRechargeManager.h"
#import "PublicRoomManager.h"
#import "RoomBoomManager.h"
@interface BaseMvpPresenter()
@@ -37,13 +38,16 @@
// 2.
[[PublicRoomManager sharedManager] reset];
// 3. logout
// 3.
[[RoomBoomManager sharedManager] leaveRoom];
// 4. logout
[[AccountInfoStorage instance] saveAccountInfo:nil];
[[AccountInfoStorage instance] saveTicket:nil];
if ([NIMSDK sharedSDK].loginManager.isLogined) {
[[NIMSDK sharedSDK].loginManager logout:nil];
}
// 4.
// 5.
[self tokenInvalid];
// ///
// [[ClientConfig shareConfig] resetHeartBratTimer];

View File

@@ -1,426 +0,0 @@
# AttachmentModel 功能分析报告
## 目录
- [1. 概述](#1-概述)
- [2. AttachmentModel 核心结构](#2-attachmentmodel-核心结构)
- [3. 消息类型分类](#3-消息类型分类)
- [4. 使用场景分析](#4-使用场景分析)
- [5. 功能模块分布](#5-功能模块分布)
- [6. 关键实现细节](#6-关键实现细节)
- [7. 最佳实践](#7-最佳实践)
- [8. 总结](#8-总结)
## 1. 概述
### 1.1 定义
`AttachmentModel` 是YuMi项目中用于处理NIMSDK自定义消息的核心数据模型它实现了`NIMCustomAttachment`协议用于在云信SDK中传输和处理各种自定义消息类型。
### 1.2 核心作用
- **消息类型标识**: 通过`first``second`字段标识不同的消息类型和子类型
- **数据承载**: 通过`data`字段承载具体的消息内容
- **消息解析**: 配合`CustomAttachmentDecoder`进行消息的编码和解码
- **业务扩展**: 支持各种业务场景的自定义消息处理
### 1.3 设计特点
- **类型安全**: 使用枚举定义所有消息类型,避免硬编码
- **扩展性强**: 支持新增消息类型而不影响现有代码
- **统一接口**: 所有自定义消息都通过统一的接口处理
- **数据灵活**: `data`字段支持任意类型的数据结构
## 2. AttachmentModel 核心结构
### 2.1 基础属性
```objc
@interface AttachmentModel : PIBaseModel<NIMCustomAttachment>
@property (nonatomic, strong) id data; // 消息数据内容
@property (nonatomic, assign) int first; // 消息类型标识
@property (nonatomic, assign) int second; // 消息子类型标识
@property (nonatomic, assign) BOOL isBroadcast; // 是否为广播消息
@property (nonatomic, assign) NSInteger seq; // 本地序号,用于消息排序
@end
```
### 2.2 编码实现
```objc
- (NSString *)encodeAttachment {
return [self toJSONString];
}
```
### 2.3 解码实现
```objc
// CustomAttachmentDecoder.m
- (id<NIMCustomAttachment>)decodeAttachment:(NSString *)content {
id<NIMCustomAttachment> attachment;
NSData *data = [content dataUsingEncoding:NSUTF8StringEncoding];
if (data) {
NSDictionary *dict = [NSJSONSerialization JSONObjectWithData:data
options:0
error:nil];
if ([dict isKindOfClass:[NSDictionary class]]) {
int first = [dict[@"first"] intValue];
int second = [dict[@"second"] intValue];
id originalData = dict[@"data"];
AttachmentModel *model = [[AttachmentModel alloc] init];
model.first = (short)first;
model.second = (short)second;
model.data = originalData;
attachment = model;
}
}
return attachment;
}
```
## 3. 消息类型分类
### 3.1 主要消息类型 (first字段)
#### 3.1.1 基础功能类
| 类型值 | 类型名称 | 功能描述 |
|--------|----------|----------|
| 2 | `CustomMessageType_Room_Tip` | 房间提示消息 |
| 3 | `CustomMessageType_Gift` | 礼物相关消息 |
| 5 | `CustomMessageType_Account` | 账户更新消息 |
| 6 | `CustomMessageType_Member_Online` | 关注主播上线通知 |
| 8 | `CustomMessageType_Queue` | 队列操作消息 |
| 9 | `CustomMessageType_Face` | 表情消息 |
| 10 | `CustomMessageType_Tweet` | 推文消息 |
| 12 | `CustomMessageType_AllMicroSend` | 全麦送礼物 |
#### 3.1.2 房间管理类
| 类型值 | 类型名称 | 功能描述 |
|--------|----------|----------|
| 15 | `CustomMessageType_Car_Notify` | 座驾相关通知 |
| 18 | `CustomMessageType_Kick_User` | 踢出房间消息 |
| 20 | `CustomMessageType_Update_RoomInfo` | 房间信息更新 |
| 30 | `CustomMessageType_Arrange_Mic` | 排麦相关消息 |
| 31 | `CustomMessageType_Room_PK` | 房间内PK消息 |
| 42 | `CustomMessageType_Room_GiftValue` | 房间礼物值同步 |
#### 3.1.3 社交功能类
| 类型值 | 类型名称 | 功能描述 |
|--------|----------|----------|
| 19 | `CustomMessageType_Secretary` | 小秘书消息 |
| 22 | `CustomMessageType_Application_Share` | 应用内分享 |
| 52 | `CustomMessageType_Monents` | 动态相关消息 |
| 60 | `CustomMessageType_RedPacket` | 红包相关消息 |
| 62 | `CustomMessageType_FindNew` | 发现萌新消息 |
| 64 | `CustomMessageType_CP` | CP礼物消息 |
#### 3.1.4 游戏娱乐类
| 类型值 | 类型名称 | 功能描述 |
|--------|----------|----------|
| 26 | `CustomMessageType_Candy_Tree` | 糖果树消息 |
| 63 | `CustomMessageType_RoomBoom` | 房间火箭消息 |
| 71 | `CustomMessageType_Tarot` | 塔罗牌消息 |
| 72 | `CustomMessageType_RoomPlay_Dating` | 相亲游戏消息 |
| 81 | `CustomMessageType_Room_Sailing` | 航海游戏消息 |
| 83 | `CustomMessageType_Across_Room_PK` | 跨房PK消息 |
| 97 | `CustomMessageType_Treasure_Fairy` | 精灵密藏消息 |
#### 3.1.5 系统通知类
| 类型值 | 类型名称 | 功能描述 |
|--------|----------|----------|
| 23 | `CustomMessageType_Message_Handle` | 系统通知消息 |
| 24 | `CustomMessageType_User_UpGrade` | 用户升级消息 |
| 49 | `CustomMessageType_Version_Update` | 版本升级消息 |
| 75 | `CustomMessageType_Chat_Risk_Alert` | 私聊风险提醒 |
| 76 | `CustomMessageType_First_Recharge_Reward` | 首充奖励消息 |
| 78 | `CustomMessageType_First_VisitorRecord` | 访客记录消息 |
| 92 | `CustomMessageType_Task_Complete` | 任务完成通知 |
### 3.2 子类型示例 (second字段)
#### 3.2.1 礼物消息子类型
```objc
typedef NS_ENUM(NSUInteger, CustomMessageSubGift) {
Custom_Message_Sub_Gift_Send = 31, // 发送礼物
Custom_Message_Sub_Gift_ChannelNotify = 32, // 全服发送礼物
Custom_Message_Sub_Gift_LuckySend = 34, // 发送福袋礼物
Custom_Message_Sub_Gift_EmbeddedStyle = 35, // 发送嵌入式礼物
};
```
#### 3.2.2 红包消息子类型
```objc
typedef NS_ENUM(NSUInteger, CustomMessageSubRedPacket) {
Custom_Message_Sub_RoomGiftRedPacket = 601, // 房间礼物红包
Custom_Message_Sub_RoomDiamandRedPacket = 602, // 房间钻石红包
Custom_Message_Sub_AllGiftRedPacket = 603, // 全服礼物红包
Custom_Message_Sub_AllDiamandRedPacket = 604, // 全服钻石红包
Custom_Message_Sub_OpenRedPacketSuccess = 605, // 抢红包成功
Custom_Message_Sub_NewRoomDiamandRedPacket = 606, // 新版本房间钻石红包
Custom_Message_Sub_LuckyPackage = 607, // 最新版本房间红包推送
};
```
#### 3.2.3 房间PK子类型
```objc
typedef NS_ENUM(NSUInteger, CustomMessageSubRoomPK) {
Custom_Message_Sub_Room_PK_Non_Empty = 311, // 从无人报名pk排麦到有人报名pk排麦
Custom_Message_Sub_Room_PK_Empty = 312, // 从有人报名pk排麦到无人报名pk排麦
Custom_Message_Sub_Room_PK_Mode_Open = 313, // 创建了pk模式
Custom_Message_Sub_Room_PK_Mode_Close = 314, // 关闭pk模式
Custom_Message_Sub_Room_PK_Start = 315, // pk开始
Custom_Message_Sub_Room_PK_Result = 316, // pk结果
Custom_Message_Sub_Room_PK_Re_Start = 317, // 重新开始
Custom_Message_Sub_Room_PK_Manager_Up_Mic = 318, // 管理员邀请上麦
};
```
## 4. 使用场景分析
### 4.1 消息接收处理
#### 4.1.1 私聊消息处理
```objc
// TabbarViewController.m
- (void)onRecvMessages:(NSArray<NIMMessage *> *)messages {
for (NIMMessage *message in messages) {
if (message.session.sessionType == NIMSessionTypeP2P) {
if (message.messageType == NIMMessageTypeCustom) {
NIMCustomObject *obj = (NIMCustomObject *)message.messageObject;
if (obj.attachment != nil && [obj.attachment isKindOfClass:[AttachmentModel class]]) {
AttachmentModel *attachment = (AttachmentModel *)obj.attachment;
// 处理发现萌新打招呼消息
if (attachment.first == CustomMessageType_FindNew &&
attachment.second == Custom_Message_Find_New_Greet_New_User) {
FindNewGreetMessageModel *greetInfo = [FindNewGreetMessageModel modelWithDictionary:attachment.data];
// 显示打招呼弹窗
[self showGreetAlert:greetInfo];
}
}
}
}
}
}
```
#### 4.1.2 广播消息处理
```objc
// TabbarViewController.m
- (void)onReceiveBroadcastMessage:(NIMBroadcastMessage *)broadcastMessage {
if (broadcastMessage.content) {
NSDictionary *msgDictionary = [broadcastMessage.content toJSONObject];
AttachmentModel *attachment = [AttachmentModel modelWithJSON:msgDictionary[@"body"]];
// 处理红包消息
if (attachment.first == CustomMessageType_RedPacket) {
[self receiveRedPacketDealWithData:attachment];
}
// 处理版本更新消息
else if (attachment.first == CustomMessageType_Version_Update &&
attachment.second == Custom_Message_Version_Update_Value) {
[self handleVersionUpdate:attachment];
}
}
}
```
### 4.2 消息发送
#### 4.2.1 创建自定义消息
```objc
// 创建礼物消息
AttachmentModel *attachment = [[AttachmentModel alloc] init];
attachment.first = CustomMessageType_Gift;
attachment.second = Custom_Message_Sub_Gift_Send;
attachment.data = @{
@"giftId": @"123",
@"giftName": @"玫瑰花",
@"giftCount": @1,
@"senderId": @"user123",
@"receiverId": @"user456"
};
// 创建NIM消息
NIMCustomObject *customObject = [[NIMCustomObject alloc] init];
customObject.attachment = attachment;
NIMMessage *message = [[NIMMessage alloc] init];
message.messageObject = customObject;
```
#### 4.2.2 发送消息
```objc
// 发送到指定会话
NIMSession *session = [NIMSession session:@"receiverId" type:NIMSessionTypeP2P];
[[NIMSDK sharedSDK].chatManager sendMessage:message toSession:session error:nil];
```
### 4.3 消息内容显示
#### 4.3.1 消息内容解析
```objc
// NIMMessageUtils.m
+ (NSString *)messageContent:(NIMMessage*)message {
switch (message.messageType) {
case NIMMessageTypeCustom: {
NIMCustomObject *obj = (NIMCustomObject *)message.messageObject;
AttachmentModel *attachment = (AttachmentModel *)obj.attachment;
if (attachment.first == CustomMessageType_Secretary) {
if (attachment.second == Custom_Message_Sub_Secretary_Router) {
return attachment.data[@"title"];
}
} else if (attachment.first == CustomMessageType_Gift) {
if (attachment.second == Custom_Message_Sub_Gift_Send) {
return YMLocalizedString(@"NIMMessageUtils5"); // "发送了礼物"
}
} else if (attachment.first == CustomMessageType_FindNew &&
attachment.second == Custom_Message_Find_New_Greet_New_User) {
NSString *text = attachment.data[@"message"];
return text.length > 0 ? text : YMLocalizedString(@"NIMMessageUtils11");
}
// ... 其他消息类型处理
}
break;
}
return @"";
}
```
## 5. 功能模块分布
### 5.1 消息模块 (YMMessage)
- **文件**: `AttachmentModel.h/m`, `CustomAttachmentDecoder.h/m`
- **功能**: 消息模型定义、解码器实现
- **使用**: 所有自定义消息的基础结构
### 5.2 主界面模块 (YMTabbar)
- **文件**: `TabbarViewController.m`
- **功能**: 全局消息接收处理、广播消息处理
- **使用**: 处理发现萌新、红包、版本更新等全局消息
### 5.3 房间模块 (YMRoom)
- **文件**: 多个房间相关文件
- **功能**: 房间内消息处理、PK、礼物、火箭等
- **使用**: 处理房间内的各种互动消息
### 5.4 动态模块 (YMMonents)
- **文件**: `XPMomentsViewController.m`
- **功能**: 动态相关消息处理
- **使用**: 处理动态分享、审核等消息
### 5.5 个人中心模块 (YMMine)
- **文件**: 多个个人中心相关文件
- **功能**: 个人相关消息处理
- **使用**: 处理VIP、粉丝团、任务等个人消息
## 6. 关键实现细节
### 6.1 消息类型判断
```objc
// 判断是否为特定类型的消息
if (attachment.first == CustomMessageType_Gift &&
attachment.second == Custom_Message_Sub_Gift_Send) {
// 处理发送礼物消息
}
// 判断是否为系统消息
if (attachment.first == CustomMessageType_System_message) {
// 处理系统消息
}
```
### 6.2 数据解析
```objc
// 从data字段解析具体数据
NSDictionary *giftData = attachment.data;
NSString *giftId = giftData[@"giftId"];
NSString *giftName = giftData[@"giftName"];
NSNumber *giftCount = giftData[@"giftCount"];
// 使用MJExtension进行模型转换
GiftModel *giftModel = [GiftModel modelWithDictionary:attachment.data];
```
### 6.3 消息过滤
```objc
// 根据分区ID过滤消息
NSString *partitionId = [NSString stringWithFormat:@"%@", attachment.data[@"partitionId"]];
if (![partitionId isEqualToString:self.userInfo.partitionId]) {
return; // 不是当前分区的消息,忽略
}
```
### 6.4 消息排序
```objc
// 使用seq字段进行消息排序
@property (nonatomic, assign) NSInteger seq; // 本地序号,用于将一条消息分解为多条有次序的消息
```
## 7. 最佳实践
### 7.1 消息类型定义
1. **使用枚举**: 避免硬编码数字,提高代码可读性
2. **分类管理**: 按功能模块分类管理消息类型
3. **版本兼容**: 新增消息类型时保持向后兼容
### 7.2 消息处理
1. **类型检查**: 在处理消息前先检查类型
2. **数据验证**: 验证data字段的数据完整性
3. **错误处理**: 对解析失败的消息进行适当处理
### 7.3 性能优化
1. **消息过滤**: 根据业务需求过滤不需要的消息
2. **内存管理**: 及时释放不需要的消息对象
3. **批量处理**: 对大量消息进行批量处理
### 7.4 扩展性设计
1. **模块化**: 按功能模块组织消息处理逻辑
2. **插件化**: 支持新增消息类型而不影响现有代码
3. **配置化**: 通过配置文件管理消息类型
## 8. 总结
### 8.1 核心价值
`AttachmentModel` 作为YuMi项目的消息处理核心具有以下价值
1. **统一接口**: 为所有自定义消息提供统一的处理接口
2. **类型安全**: 通过枚举定义确保消息类型的类型安全
3. **扩展性强**: 支持灵活扩展新的消息类型
4. **功能完整**: 覆盖了社交、游戏、系统等各个业务场景
### 8.2 技术特点
1. **协议实现**: 实现了NIMSDK的`NIMCustomAttachment`协议
2. **JSON序列化**: 支持JSON格式的消息编码和解码
3. **模型转换**: 支持与业务模型的相互转换
4. **类型枚举**: 使用枚举定义所有消息类型和子类型
### 8.3 业务覆盖
`AttachmentModel` 覆盖了YuMi项目的所有主要业务场景
- **社交功能**: 私聊、动态、关注等
- **房间功能**: PK、礼物、火箭、红包等
- **游戏功能**: 塔罗、航海、相亲等
- **系统功能**: 升级、任务、通知等
- **商业功能**: VIP、粉丝团、充值等
### 8.4 架构优势
1. **解耦合**: 消息处理逻辑与业务逻辑分离
2. **可维护**: 清晰的消息类型定义和处理流程
3. **可测试**: 每个消息类型都可以独立测试
4. **可扩展**: 新增功能时只需添加新的消息类型
`AttachmentModel` 是YuMi项目即时通讯功能的重要基础设施为项目的各种业务场景提供了强大而灵活的消息处理能力。
---
**文档版本**: 1.0
**最后更新**: 2024年12月
**维护人员**: 开发团队

View File

@@ -1,248 +0,0 @@
# Banner手势优化实施总结
## 概述
本文档记录了在 `RoomAnimationView.m` 中对 banner 手势系统的优化实施过程。
## 最新优化内容2025年1月
### 需求描述
1. **bannerContainer 手势范围调整**
- 中央宽度 2/3 的位置:保留 swipe 手势
- 左右两侧各 1/6 宽度:添加 tap 手势
2. **tap 手势处理逻辑**
- 检查当前显示的 banner 是否在 tap 位置可以响应事件
- 如果可以响应:不处理,让 banner 继续原有逻辑
- 如果不能响应:保存 tap 位置点,供后续使用
### 实施方案
#### 1. 手势识别器重新设计
```objc
- (void)addBnnerContainGesture {
// 创建独立的手势容器避免与XPRoomAnimationHitView的hitTest冲突
[self insertSubview:self.bannerSwipeGestureContainer aboveSubview:self.bannerContainer];
[self insertSubview:self.bannerLeftTapGestureContainer aboveSubview:self.bannerContainer];
[self insertSubview:self.bannerRightTapGestureContainer aboveSubview:self.bannerContainer];
// 设置手势容器的布局约束
[self.bannerSwipeGestureContainer mas_makeConstraints:^(MASConstraintMaker *make) {
make.center.mas_equalTo(self.bannerContainer);
make.top.bottom.mas_equalTo(self.bannerContainer);
make.width.mas_equalTo(self.bannerContainer.mas_width).multipliedBy(2.0/3.0);
}];
[self.bannerLeftTapGestureContainer mas_makeConstraints:^(MASConstraintMaker *make) {
make.top.leading.bottom.mas_equalTo(self.bannerContainer);
make.trailing.mas_equalTo(self.bannerSwipeGestureContainer.mas_leading);
}];
[self.bannerRightTapGestureContainer mas_makeConstraints:^(MASConstraintMaker *make) {
make.top.trailing.bottom.mas_equalTo(self.bannerContainer);
make.leading.mas_equalTo(self.bannerSwipeGestureContainer.mas_trailing);
}];
// 创建中央区域的 swipe 手势2/3 宽度)
UISwipeGestureRecognizer *swipe = [[UISwipeGestureRecognizer alloc] initWithTarget:self
action:@selector(handleSwipe)];
if (isMSRTL()) {
swipe.direction = UISwipeGestureRecognizerDirectionRight;
} else {
swipe.direction = UISwipeGestureRecognizerDirectionLeft;
}
swipe.delegate = self;
// 创建左侧区域的 tap 手势1/6 宽度)
UITapGestureRecognizer *leftTap = [[UITapGestureRecognizer alloc] initWithTarget:self
action:@selector(handleBannerTap:)];
leftTap.delegate = self;
// 创建右侧区域的 tap 手势1/6 宽度)
UITapGestureRecognizer *rightTap = [[UITapGestureRecognizer alloc] initWithTarget:self
action:@selector(handleBannerTap:)];
rightTap.delegate = self;
// 添加手势识别器到对应的手势容器
[self.bannerSwipeGestureContainer addGestureRecognizer:swipe];
[self.bannerLeftTapGestureContainer addGestureRecognizer:leftTap];
[self.bannerRightTapGestureContainer addGestureRecognizer:rightTap];
}
```
#### 2. 区域划分逻辑
```objc
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch {
CGPoint touchPoint = [touch locationInView:self.bannerContainer];
CGFloat containerWidth = self.bannerContainer.bounds.size.width;
// 计算区域边界
CGFloat leftBoundary = containerWidth / 6.0; // 1/6 宽度
CGFloat rightBoundary = containerWidth * 5.0 / 6.0; // 5/6 宽度
if ([gestureRecognizer isKindOfClass:[UISwipeGestureRecognizer class]]) {
// Swipe 手势只在中央 2/3 区域生效
return touchPoint.x >= leftBoundary && touchPoint.x <= rightBoundary;
} else if ([gestureRecognizer isKindOfClass:[UITapGestureRecognizer class]]) {
// Tap 手势只在左右两侧 1/6 区域生效
return touchPoint.x < leftBoundary || touchPoint.x > rightBoundary;
}
return YES;
}
```
#### 3. Tap 手势处理逻辑
```objc
- (void)handleBannerTap:(UITapGestureRecognizer *)tapGesture {
CGPoint tapPoint = [tapGesture locationInView:self.bannerContainer];
// 检查当前显示的 banner 是否在 tap 位置可以响应事件
if ([self isPointInBannerInteractiveArea:tapPoint]) {
// banner 可以响应,不处理,让 banner 继续原有逻辑
NSLog(@"🎯 Banner tap 位置在可交互区域banner 将处理此事件");
return;
} else {
// banner 不能响应,保存 tap 位置
self.savedTapPoint = tapPoint;
self.hasSavedTapPoint = YES;
NSLog(@"💾 Banner tap 位置不在可交互区域,已保存位置: %@", NSStringFromCGPoint(tapPoint));
}
}
```
#### 4. Banner 交互区域检查
```objc
- (BOOL)isPointInBannerInteractiveArea:(CGPoint)point {
// 检查当前显示的 banner 是否在指定位置可以响应事件
for (UIView *subview in self.bannerContainer.subviews) {
if (subview.hidden || subview.alpha <= 0.01) {
continue;
}
// 检查点是否在子视图范围内
if (CGRectContainsPoint(subview.bounds, point)) {
// 检查子视图是否支持用户交互
if (subview.userInteractionEnabled) {
// 进一步检查子视图是否有可点击的元素
CGPoint subviewPoint = [subview convertPoint:point fromView:self.bannerContainer];
UIView *hitView = [subview hitTest:subviewPoint withEvent:nil];
if (hitView && hitView.userInteractionEnabled) {
return YES;
}
}
}
}
return NO;
}
```
#### 5. 公共接口方法
```objc
// 获取保存的 tap 位置
- (CGPoint)getSavedTapPoint;
// 检查是否有保存的 tap 位置
- (BOOL)hasSavedTapPointAvailable;
// 清除保存的 tap 位置
- (void)clearSavedTapPoint;
```
### 新增属性
```objc
// Banner 手势相关属性
@property(nonatomic, assign) CGPoint savedTapPoint;
@property(nonatomic, assign) BOOL hasSavedTapPoint;
// 手势容器使用普通UIView避免XPRoomAnimationHitView的hitTest冲突
@property(nonatomic, strong) UIView *bannerSwipeGestureContainer;
@property(nonatomic, strong) UIView *bannerLeftTapGestureContainer;
@property(nonatomic, strong) UIView *bannerRightTapGestureContainer;
```
### 协议支持
- 添加了 `UIGestureRecognizerDelegate` 协议支持
- 实现了手势识别器的 delegate 方法
## 技术特点
### 1. 精确的区域控制
- 使用独立的手势容器精确划分区域
- 中央 2/3 区域swipe 手势容器
- 左右两侧各 1/6 区域tap 手势容器
### 2. 避免手势冲突
- 使用普通 `UIView` 作为手势容器,避免 `XPRoomAnimationHitView``hitTest` 冲突
- 手势容器独立于 banner 内容,确保手势识别不受干扰
### 3. 智能的事件处理
- 检查 banner 是否在 tap 位置可响应
- 自动判断是否需要保存 tap 位置
- 避免与 banner 原有交互逻辑冲突
### 4. 灵活的接口设计
- 提供公共方法获取保存的 tap 位置
- 支持清除保存的位置
- 便于外部代码使用
### 5. 完善的日志记录
- 详细记录手势处理过程
- 便于调试和问题排查
## 使用示例
```objc
// 检查是否有保存的 tap 位置
if ([roomAnimationView hasSavedTapPointAvailable]) {
CGPoint savedPoint = [roomAnimationView getSavedTapPoint];
NSLog(@"保存的 tap 位置: %@", NSStringFromCGPoint(savedPoint));
// 使用保存的位置进行后续处理
// ...
// 清除保存的位置
[roomAnimationView clearSavedTapPoint];
}
```
## 注意事项
1. **手势容器设计**:使用普通 `UIView` 作为手势容器,避免 `XPRoomAnimationHitView``hitTest` 冲突
2. **区域划分**:通过独立的视图容器精确划分手势区域,确保手势识别不受干扰
3. **交互检查**:通过 `hitTest` 方法检查子视图的实际可交互性
4. **内存管理**:及时清除不需要的 tap 位置数据
5. **调试支持**:在 DEBUG 模式下为手势容器添加背景色,便于调试区域划分
## 测试建议
1. **区域划分测试**
- 在中央区域测试 swipe 手势
- 在左右两侧测试 tap 手势
- 验证手势在错误区域不触发
2. **交互逻辑测试**
- 在有可交互 banner 的区域 tap
- 在无可交互 banner 的区域 tap
- 验证 tap 位置的保存和清除
3. **边界条件测试**
- 测试不同屏幕尺寸下的区域划分
- 测试 RTL 语言环境下的手势方向
- 测试多个 banner 同时显示的情况
## 总结
本次优化成功实现了:
- ✅ bannerContainer 手势范围的精确划分
- ✅ 智能的 tap 手势处理逻辑
- ✅ 灵活的 tap 位置保存机制
- ✅ 完善的公共接口设计
- ✅ 与现有代码的良好兼容性
- ✅ 解决了 XPRoomAnimationHitView 的手势冲突问题
### 关键改进
1. **避免手势冲突**:使用普通 `UIView` 作为手势容器,避免 `XPRoomAnimationHitView``hitTest` 方法干扰
2. **精确区域控制**:通过独立的视图容器实现精确的手势区域划分
3. **调试友好**:在 DEBUG 模式下为手势容器添加背景色,便于调试
该方案既满足了新的功能需求,又解决了潜在的手势冲突问题,保持了代码的可维护性和扩展性。

View File

@@ -1,434 +0,0 @@
# 邮箱验证码登录流程文档
## 概述
本文档详细描述了 YuMi iOS 应用中 `LoginTypesViewController``LoginDisplayType_email` 模式下的邮箱验证码登录流程。该流程实现了基于邮箱和验证码的用户认证机制。
## 系统架构
### 核心组件
- **LoginTypesViewController**: 登录类型控制器,负责 UI 展示和用户交互
- **LoginPresenter**: 登录业务逻辑处理器,负责与 API 交互
- **LoginInputItemView**: 输入组件,提供邮箱和验证码输入界面
- **Api+Login**: 登录相关 API 接口封装
- **AccountInfoStorage**: 账户信息本地存储管理
### 数据模型
#### LoginDisplayType 枚举
```objc
typedef NS_ENUM(NSUInteger, LoginDisplayType) {
LoginDisplayType_id, // ID 登录
LoginDisplayType_email, // 邮箱登录 ✓
LoginDisplayType_phoneNum, // 手机号登录
LoginDisplayType_email_forgetPassword, // 邮箱忘记密码
LoginDisplayType_phoneNum_forgetPassword, // 手机号忘记密码
};
```
#### LoginInputType 枚举
```objc
typedef NS_ENUM(NSUInteger, LoginInputType) {
LoginInputType_email, // 邮箱输入
LoginInputType_verificationCode, // 验证码输入
LoginInputType_login, // 登录按钮
// ... 其他类型
};
```
#### GetSmsType 验证码类型
```objc
typedef NS_ENUM(NSUInteger, GetSmsType) {
GetSmsType_Regist = 1, // 注册(邮箱登录使用此类型)
GetSmsType_Login = 2, // 登录
GetSmsType_Reset_Password = 3, // 重设密码
// ... 其他类型
};
```
## 登录流程详解
### 1. 界面初始化流程
#### 1.1 控制器初始化
```objc
// 在 LoginViewController 中点击邮箱登录按钮
- (void)didTapEntrcyButton:(UIButton *)sender {
if (sender.tag == LoginType_Email) {
LoginTypesViewController *vc = [[LoginTypesViewController alloc] init];
[self.navigationController pushViewController:vc animated:YES];
[vc updateLoginType:LoginDisplayType_email]; // 设置为邮箱登录模式
}
}
```
#### 1.2 输入区域设置
```objc
- (void)setupEmailInputArea {
[self setupInpuArea:LoginInputType_email // 第一行:邮箱输入
second:LoginInputType_verificationCode // 第二行:验证码输入
third:LoginInputType_none // 第三行:无
action:LoginInputType_login // 操作按钮:登录
showForgetPassword:NO]; // 不显示忘记密码
}
```
#### 1.3 UI 组件配置
- **第一行输入框**: 邮箱地址输入
- 占位符: "请输入邮箱地址"
- 键盘类型: `UIKeyboardTypeEmailAddress`
- 回调: `handleFirstInputContentUpdate`
- **第二行输入框**: 验证码输入
- 占位符: "请输入验证码"
- 键盘类型: `UIKeyboardTypeDefault`
- 附带"获取验证码"按钮
- 回调: `handleSecondInputContentUpdate`
### 2. 验证码获取流程
#### 2.1 用户交互触发
```objc
// 用户点击"获取验证码"按钮
[self.secondLineInputView setHandleItemAction:^(LoginInputType inputType) {
if (inputType == LoginInputType_verificationCode) {
if (self.type == LoginDisplayType_email) {
[self handleTapGetMailVerificationCode];
}
}
}];
```
#### 2.2 邮箱验证码获取处理
```objc
- (void)handleTapGetMailVerificationCode {
NSString *email = [self.firstLineInputView inputContent];
// 邮箱地址验证
if (email.length == 0) {
[self.secondLineInputView endVerificationCountDown];
return;
}
// 调用 Presenter 发送验证码
[self.presenter sendMailVerificationCode:email type:GetSmsType_Regist];
}
```
#### 2.3 Presenter 层处理
```objc
- (void)sendMailVerificationCode:(NSString *)emailAddress type:(NSInteger)type {
// DES 加密邮箱地址
NSString *desEmail = [DESEncrypt encryptUseDES:emailAddress
key:KeyWithType(KeyType_PasswordEncode)];
@kWeakify(self);
[Api emailGetCode:[self createHttpCompletion:^(BaseModel *data) {
@kStrongify(self);
if ([[self getView] respondsToSelector:@selector(emailCodeSucess:type:)]) {
[[self getView] emailCodeSucess:@"" type:type];
}
} fail:^(NSInteger code, NSString *msg) {
@kStrongify(self);
if ([[self getView] respondsToSelector:@selector(emailCodeFailure)]) {
[[self getView] emailCodeFailure];
}
} showLoading:YES errorToast:YES]
emailAddress:desEmail
type:@(type)];
}
```
#### 2.4 API 接口调用
```objc
+ (void)emailGetCode:(HttpRequestHelperCompletion)completion
emailAddress:(NSString *)emailAddress
type:(NSNumber *)type {
[self makeRequest:@"email/getCode"
method:HttpRequestHelperMethodPOST
completion:completion, __FUNCTION__, emailAddress, type, nil];
}
```
**API 详情**:
- **接口路径**: `POST /email/getCode`
- **请求参数**:
- `emailAddress`: 邮箱地址DES 加密)
- `type`: 验证码类型1=注册)
#### 2.5 获取验证码成功处理
```objc
- (void)emailCodeSucess:(NSString *)message type:(GetSmsType)type {
[self showSuccessToast:YMLocalizedString(@"XPLoginPhoneViewController2")]; // "验证码已发送"
[self.secondLineInputView startVerificationCountDown]; // 开始倒计时
[self.secondLineInputView displayKeyboard]; // 显示键盘
}
```
#### 2.6 获取验证码失败处理
```objc
- (void)emailCodeFailure {
[self.secondLineInputView endVerificationCountDown]; // 结束倒计时
}
```
### 3. 邮箱登录流程
#### 3.1 登录按钮状态检查
```objc
- (void)checkActionButtonStatus {
switch (self.type) {
case LoginDisplayType_email: {
NSString *accountString = [self.firstLineInputView inputContent]; // 邮箱
NSString *codeString = [self.secondLineInputView inputContent]; // 验证码
// 只有当邮箱和验证码都不为空时才启用登录按钮
if (![NSString isEmpty:accountString] && ![NSString isEmpty:codeString]) {
self.bottomActionButton.enabled = YES;
} else {
self.bottomActionButton.enabled = NO;
}
}
break;
}
}
```
#### 3.2 登录按钮点击处理
```objc
- (void)didTapActionButton {
[self.view endEditing:true];
switch (self.type) {
case LoginDisplayType_email: {
// 调用 Presenter 进行邮箱登录
[self.presenter loginWithEmail:[self.firstLineInputView inputContent]
code:[self.secondLineInputView inputContent]];
}
break;
}
}
```
#### 3.3 Presenter 层登录处理
```objc
- (void)loginWithEmail:(NSString *)email code:(NSString *)code {
// DES 加密邮箱地址
NSString *desMail = [DESEncrypt encryptUseDES:email
key:KeyWithType(KeyType_PasswordEncode)];
@kWeakify(self);
[Api loginWithCode:[self createHttpCompletion:^(BaseModel *data) {
@kStrongify(self);
// 解析账户模型
AccountModel *accountModel = [AccountModel modelWithDictionary:data.data];
// 保存账户信息
if (accountModel && accountModel.access_token.length > 0) {
[[AccountInfoStorage instance] saveAccountInfo:accountModel];
}
// 通知登录成功
if ([[self getView] respondsToSelector:@selector(loginSuccess)]) {
[[self getView] loginSuccess];
}
} fail:^(NSInteger code, NSString *msg) {
@kStrongify(self);
[[self getView] loginFailWithMsg:msg];
} errorToast:NO]
email:desMail
code:code
client_secret:clinet_s // 客户端密钥
version:@"1"
client_id:@"erban-client"
grant_type:@"email"]; // 邮箱登录类型
}
```
#### 3.4 API 接口调用
```objc
+ (void)loginWithCode:(HttpRequestHelperCompletion)completion
email:(NSString *)email
code:(NSString *)code
client_secret:(NSString *)client_secret
version:(NSString *)version
client_id:(NSString *)client_id
grant_type:(NSString *)grant_type {
NSString *fang = [NSString stringFromBase64String:@"b2F1dGgvdG9rZW4="]; // oauth/token
[self makeRequest:fang
method:HttpRequestHelperMethodPOST
completion:completion, __FUNCTION__, email, code, client_secret,
version, client_id, grant_type, nil];
}
```
**API 详情**:
- **接口路径**: `POST /oauth/token`
- **请求参数**:
- `email`: 邮箱地址DES 加密)
- `code`: 验证码
- `client_secret`: 客户端密钥
- `version`: 版本号 "1"
- `client_id`: 客户端ID "erban-client"
- `grant_type`: 授权类型 "email"
#### 3.5 登录成功处理
```objc
- (void)loginSuccess {
[self showSuccessToast:YMLocalizedString(@"XPLoginPhoneViewController1")]; // "登录成功"
[PILoginManager loginWithVC:self isLoginPhone:NO]; // 执行登录后续处理
}
```
#### 3.6 登录失败处理
```objc
- (void)loginFailWithMsg:(NSString *)msg {
[self showSuccessToast:msg]; // 显示错误信息
}
```
## 数据流时序图
```mermaid
sequenceDiagram
participant User as 用户
participant VC as LoginTypesViewController
participant IV as LoginInputItemView
participant P as LoginPresenter
participant API as Api+Login
participant Storage as AccountInfoStorage
Note over User,Storage: 1. 初始化邮箱登录界面
User->>VC: 选择邮箱登录
VC->>VC: updateLoginType(LoginDisplayType_email)
VC->>VC: setupEmailInputArea()
VC->>IV: 创建邮箱输入框
VC->>IV: 创建验证码输入框
Note over User,Storage: 2. 获取邮箱验证码
User->>IV: 输入邮箱地址
User->>IV: 点击"获取验证码"
IV->>VC: handleTapGetMailVerificationCode
VC->>VC: 验证邮箱地址非空
VC->>P: sendMailVerificationCode(email, GetSmsType_Regist)
P->>P: DES加密邮箱地址
P->>API: emailGetCode(encryptedEmail, type=1)
API-->>P: 验证码发送结果
P-->>VC: emailCodeSucess / emailCodeFailure
VC->>IV: startVerificationCountDown / endVerificationCountDown
VC->>User: 显示成功/失败提示
Note over User,Storage: 3. 邮箱验证码登录
User->>IV: 输入验证码
IV->>VC: 输入内容变化回调
VC->>VC: checkActionButtonStatus()
VC->>User: 启用/禁用登录按钮
User->>VC: 点击登录按钮
VC->>VC: didTapActionButton()
VC->>P: loginWithEmail(email, code)
P->>P: DES加密邮箱地址
P->>API: loginWithCode(email, code, ...)
API-->>P: OAuth Token 响应
P->>P: 解析 AccountModel
P->>Storage: saveAccountInfo(accountModel)
P-->>VC: loginSuccess / loginFailWithMsg
VC->>User: 显示登录结果
VC->>User: 跳转到主界面
```
## 安全机制
### 1. 数据加密
- **邮箱地址加密**: 使用 DES 算法加密邮箱地址后传输
```objc
NSString *desEmail = [DESEncrypt encryptUseDES:email key:KeyWithType(KeyType_PasswordEncode)];
```
### 2. 输入验证
- **邮箱格式验证**: 通过 `UIKeyboardTypeEmailAddress` 键盘类型引导正确输入
- **非空验证**: 邮箱和验证码都必须非空才能执行登录
### 3. 验证码安全
- **时效性**: 验证码具有倒计时机制,防止重复获取
- **类型标识**: 使用 `GetSmsType_Regist = 1` 标识登录验证码
### 4. 网络安全
- **错误处理**: 完整的成功/失败回调机制
- **加载状态**: `showLoading:YES` 防止重复请求
- **错误提示**: `errorToast:YES` 显示网络错误
## 错误处理机制
### 1. 邮箱验证码获取错误
```objc
- (void)emailCodeFailure {
[self.secondLineInputView endVerificationCountDown]; // 停止倒计时
// 用户可以重新获取验证码
}
```
### 2. 登录失败处理
```objc
- (void)loginFailWithMsg:(NSString *)msg {
[self showSuccessToast:msg]; // 显示具体错误信息
// 用户可以重新尝试登录
}
```
### 3. 网络请求错误
- **自动重试**: 用户可以手动重新点击获取验证码或登录
- **错误提示**: 通过 Toast 显示具体错误信息
- **状态恢复**: 失败后恢复按钮可点击状态
## 本地化支持
### 关键文本资源
- `@"20.20.51_text_1"`: "邮箱登录"
- `@"20.20.51_text_4"`: "请输入邮箱地址"
- `@"20.20.51_text_7"`: "请输入验证码"
- `@"XPLoginPhoneViewController2"`: "验证码已发送"
- `@"XPLoginPhoneViewController1"`: "登录成功"
### 多语言支持
- 简体中文 (`zh-Hant.lproj`)
- 英文 (`en.lproj`)
- 阿拉伯语 (`ar.lproj`)
- 土耳其语 (`tr.lproj`)
## 依赖组件
### 外部框架
- **MASConstraintMaker**: 自动布局
- **ReactiveObjC**: 响应式编程(部分组件使用)
### 内部组件
- **YMLocalizedString**: 本地化字符串管理
- **DESEncrypt**: DES 加密工具
- **AccountInfoStorage**: 账户信息存储
- **HttpRequestHelper**: 网络请求管理
## 扩展和维护
### 新增功能建议
1. **邮箱格式验证**: 添加正则表达式验证邮箱格式
2. **验证码长度限制**: 限制验证码输入长度
3. **自动填充**: 支持系统邮箱自动填充
4. **记住邮箱**: 保存最近使用的邮箱地址
### 性能优化
1. **请求去重**: 防止短时间内重复请求验证码
2. **缓存机制**: 缓存验证码倒计时状态
3. **网络优化**: 添加请求超时和重试机制
### 代码维护
1. **常量管理**: 将硬编码字符串提取为常量
2. **错误码统一**: 统一管理API错误码
3. **日志记录**: 添加详细的操作日志
## 总结
邮箱验证码登录流程是一个完整的用户认证系统,包含了界面展示、验证码获取、用户登录、数据存储等完整环节。该流程具有良好的安全性、用户体验和错误处理机制,符合现代移动应用的认证标准。
通过本文档,开发人员可以全面了解邮箱登录的实现细节,便于后续的功能扩展和维护工作。

View File

@@ -1,63 +0,0 @@
# 图片上传接口Swift 封装,腾讯云 COS
## 1. 参数模型
```swift
struct ImageUploadRequest {
let image: UIImage
let fileName: String // 如 "image/xxxx.jpg"
}
```
## 2. 接口调用方法
```swift
class ImageUploader {
static func uploadImage(
request: ImageUploadRequest,
completion: @escaping (Result<String, Error>) -> Void
) {
// 1. 压缩图片,生成 NSData
// 2. 调用腾讯云 COS SDK 上传
// 3. 返回图片 URL
}
}
```
## 3. 示例用法
```swift
let image = UIImage(named: "test.jpg")!
let request = ImageUploadRequest(image: image, fileName: "image/\(UUID().uuidString).jpg")
ImageUploader.uploadImage(request: request) { result in
switch result {
case .success(let url):
print("上传成功,图片地址:\(url)")
case .failure(let error):
print("上传失败: \(error.localizedDescription)")
}
}
```
## 4. 第三方依赖
- [QCloudCOSXML](https://github.com/tencentyun/qcloud-sdk-ios)(腾讯云 COS 官方 SDK
- [TZImagePickerController](https://github.com/banchichen/TZImagePickerController)(图片选择/裁剪,非必须)
## 5. 配置说明
- COS 配置信息appId、region、bucket、签名等需通过接口动态获取并初始化 SDK。
- 上传时需指定 bucket、object文件名、bodyNSData
- 支持自定义域名、加速域名等高级配置。
## 6. 错误处理建议
- 网络异常、图片压缩失败、SDK 上传失败等需统一处理。
- 建议统一封装 `UploadError` 类型。
## 7. 扩展建议
- 支持多图批量上传
- 支持上传进度回调
- 支持 async/await
- 可结合项目网络层统一封装

View File

@@ -1,69 +0,0 @@
# MomentsPublish 动态发布接口Swift 封装)
## 1. 参数模型
```swift
struct MomentsPublishRequest {
let uid: String
let type: String
let worldId: String?
let content: String
let resList: [String]?
}
```
## 2. 接口调用方法
```swift
class MomentsAPI {
static func publishMoment(
request: MomentsPublishRequest,
completion: @escaping (Result<Void, Error>) -> Void
) {
// 1. 构造参数字典
// 2. 发起POST请求到 dynamic/square/publish
// 3. 处理返回结果
}
}
```
## 3. 示例用法
```swift
let request = MomentsPublishRequest(
uid: "12345",
type: "1", // 0:文本 1:图片
worldId: "67890",
content: "今天很开心!",
resList: ["image_url_1", "image_url_2"]
)
MomentsAPI.publishMoment(request: request) { result in
switch result {
case .success:
print("发布成功")
case .failure(let error):
print("发布失败: \(error.localizedDescription)")
}
}
```
## 4. 参数说明
| 参数名 | 类型 | 必填 | 说明 |
|-----------|--------------|------|----------------|
| uid | String | 是 | 用户ID |
| type | String | 是 | 动态类型0文本/1图片|
| worldId | String? | 否 | 话题ID |
| content | String | 是 | 动态内容 |
| resList | [String]? | 否 | 图片资源URL数组 |
## 5. 错误处理建议
- 网络异常、参数校验、后端返回错误码均需处理
- 建议统一封装 `APIError` 类型
## 6. 扩展建议
- 支持 async/await
- 可扩展为支持更多动态类型
- 可结合项目网络层统一封装

View File

@@ -1,622 +0,0 @@
# NIMSDKManager 使用指南
## 目录
- [1. 概述](#1-概述)
- [2. Objective-C 使用示例](#2-objective-c-使用示例)
- [3. Swift 桥接使用示例](#3-swift-桥接使用示例)
- [4. 配置说明](#4-配置说明)
- [5. 最佳实践](#5-最佳实践)
- [6. 常见问题](#6-常见问题)
## 1. 概述
`NIMSDKManager` 是一个专门用于管理NIMSDK事务的统一管理类提供了配置、初始化、登录/登出等完整的功能封装。该类采用单例模式设计支持Objective-C和Swift项目使用。
### 1.1 主要功能
- **配置管理**: 统一的NIMSDK配置管理
- **初始化**: 简化的SDK初始化流程
- **登录管理**: 登录、自动登录、登出等功能
- **状态监控**: 实时监控登录状态变化
- **消息处理**: 消息发送、接收、广播等
- **推送管理**: APNS推送相关功能
- **代理管理**: 支持多代理监听
### 1.2 设计特点
- **单例模式**: 全局统一管理
- **代理模式**: 支持多代理监听
- **Block回调**: 支持异步操作回调
- **Swift兼容**: 完美支持Swift项目桥接
- **错误处理**: 完善的错误处理机制
## 2. Objective-C 使用示例
### 2.1 基本配置和初始化
```objc
// 1. 创建配置模型
NIMSDKConfigModel *config = [[NIMSDKConfigModel alloc] init];
config.appKey = KeyWithType(KeyType_NetEase);
config.apnsCername = @"pikoDevelopPush"; // DEBUG环境
config.shouldConsiderRevokedMessageUnreadCount = YES;
config.shouldSyncStickTopSessionInfos = YES;
config.enabledHttpsForInfo = NO; // DEBUG环境
config.enabledHttpsForMessage = NO; // DEBUG环境
// 2. 配置并初始化
[[NIMSDKManager sharedManager] configureWithConfig:config];
[[NIMSDKManager sharedManager] initializeWithCompletion:^(NSError * _Nullable error) {
if (error) {
NSLog(@"NIMSDK初始化失败: %@", error);
} else {
NSLog(@"NIMSDK初始化成功");
}
}];
```
### 2.2 登录管理
```objc
// 1. 设置登录状态变化监听
[[NIMSDKManager sharedManager] setLoginStatusChangeBlock:^(NIMSDKLoginStatus status) {
switch (status) {
case NIMSDKLoginStatusNotLogin:
NSLog(@"未登录");
break;
case NIMSDKLoginStatusLogging:
NSLog(@"登录中");
break;
case NIMSDKLoginStatusLogined:
NSLog(@"已登录");
break;
case NIMSDKLoginStatusLogout:
NSLog(@"已登出");
break;
case NIMSDKLoginStatusKickout:
NSLog(@"被踢出");
break;
case NIMSDKLoginStatusAutoLoginFailed:
NSLog(@"自动登录失败");
break;
}
}];
// 2. 执行登录
[[NIMSDKManager sharedManager] loginWithAccount:@"user123"
token:@"token123"
completion:^(NSError * _Nullable error) {
if (error) {
NSLog(@"登录失败: %@", error);
} else {
NSLog(@"登录成功");
}
}];
// 3. 自动登录
NSDictionary *autoLoginData = @{@"account": @"user123", @"token": @"token123"};
[[NIMSDKManager sharedManager] autoLoginWithData:autoLoginData
completion:^(NSError * _Nullable error) {
if (error) {
NSLog(@"自动登录失败: %@", error);
} else {
NSLog(@"自动登录成功");
}
}];
// 4. 登出
[[NIMSDKManager sharedManager] logoutWithCompletion:^(NSError * _Nullable error) {
if (error) {
NSLog(@"登出失败: %@", error);
} else {
NSLog(@"登出成功");
}
}];
```
### 2.3 代理监听
```objc
@interface MyViewController () <NIMSDKManagerDelegate>
@end
@implementation MyViewController
- (void)viewDidLoad {
[super viewDidLoad];
// 添加代理
[[NIMSDKManager sharedManager] addDelegate:self];
}
- (void)dealloc {
// 移除代理
[[NIMSDKManager sharedManager] removeDelegate:self];
}
#pragma mark - NIMSDKManagerDelegate
- (void)nimSDKManager:(id)manager didChangeLoginStatus:(NIMSDKLoginStatus)status {
NSLog(@"登录状态变化: %ld", (long)status);
}
- (void)nimSDKManager:(id)manager didAutoLoginFailed:(NSError *)error {
NSLog(@"自动登录失败: %@", error);
}
- (void)nimSDKManager:(id)manager didKickout:(NIMLoginKickoutResult *)result {
NSLog(@"被踢出: %@", result);
}
- (void)nimSDKManager:(id)manager didReceiveMessages:(NSArray<NIMMessage *> *)messages {
NSLog(@"收到消息: %@", messages);
}
- (void)nimSDKManager:(id)manager didReceiveBroadcastMessage:(NIMBroadcastMessage *)broadcastMessage {
NSLog(@"收到广播消息: %@", broadcastMessage);
}
@end
```
### 2.4 消息管理
```objc
// 1. 创建消息
NIMMessage *textMessage = [[NIMSDKManager sharedManager] createTextMessage:@"Hello World"];
// 2. 创建自定义消息
AttachmentModel *attachment = [[AttachmentModel alloc] init];
attachment.first = CustomMessageType_Gift;
attachment.second = Custom_Message_Sub_Gift_Send;
attachment.data = @{@"giftId": @"123", @"giftName": @"玫瑰花"};
NIMMessage *customMessage = [[NIMSDKManager sharedManager] createCustomMessageWithAttachment:attachment];
// 3. 发送消息
NIMSession *session = [NIMSession session:@"receiverId" type:NIMSessionTypeP2P];
[[NIMSDKManager sharedManager] sendMessage:textMessage
toSession:session
completion:^(NSError * _Nullable error) {
if (error) {
NSLog(@"发送失败: %@", error);
} else {
NSLog(@"发送成功");
}
}];
// 4. 获取未读消息数
NSInteger unreadCount = [[NIMSDKManager sharedManager] unreadMessageCount];
NSLog(@"未读消息数: %ld", (long)unreadCount);
// 5. 获取所有会话
NSArray<NIMRecentSession *> *sessions = [[NIMSDKManager sharedManager] allRecentSessions];
NSLog(@"会话数量: %lu", (unsigned long)sessions.count);
```
### 2.5 推送管理
```objc
// 1. 更新APNS设备Token
- (void)application:(UIApplication *)app
didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken {
[[NIMSDKManager sharedManager] updateApnsToken:deviceToken];
}
// 2. 处理推送消息
- (void)application:(UIApplication *)application
didReceiveRemoteNotification:(NSDictionary *)userInfo
fetchCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler {
BOOL handled = [[NIMSDKManager sharedManager] handlePushNotification:userInfo];
if (handled) {
completionHandler(UIBackgroundFetchResultNewData);
} else {
completionHandler(UIBackgroundFetchResultNoData);
}
}
```
## 3. Swift 桥接使用示例
### 3.1 创建桥接头文件
在Swift项目中创建桥接头文件 `YourProject-Bridging-Header.h`
```objc
//
// YourProject-Bridging-Header.h
// YourProject
//
#ifndef YourProject_Bridging_Header_h
#define YourProject_Bridging_Header_h
#import "NIMSDKManager.h"
#import "AttachmentModel.h"
#import "CustomAttachmentDecoder.h"
#endif /* YourProject_Bridging_Header_h */
```
### 3.2 Swift 使用示例
```swift
import Foundation
import UIKit
class NIMSDKService {
static let shared = NIMSDKService()
private let nimManager = NIMSDKManager.shared()
private init() {
setupNIMSDK()
}
// MARK: - 配置和初始化
private func setupNIMSDK() {
// 创建配置
let config = NIMSDKConfigModel()
config.appKey = KeyWithType(KeyType_NetEase)
config.apnsCername = "pikoDevelopPush" // DEBUG环境
config.shouldConsiderRevokedMessageUnreadCount = true
config.shouldSyncStickTopSessionInfos = true
config.enabledHttpsForInfo = false // DEBUG环境
config.enabledHttpsForMessage = false // DEBUG环境
// 配置并初始化
nimManager.configure(with: config)
nimManager.initialize { [weak self] error in
if let error = error {
print("NIMSDK初始化失败: \(error)")
} else {
print("NIMSDK初始化成功")
self?.setupDelegates()
}
}
}
private func setupDelegates() {
// 设置登录状态变化监听
nimManager.setLoginStatusChangeBlock { [weak self] status in
self?.handleLoginStatusChange(status)
}
}
// MARK: - 登录管理
func login(account: String, token: String, completion: @escaping (Error?) -> Void) {
nimManager.login(withAccount: account, token: token) { error in
DispatchQueue.main.async {
completion(error)
}
}
}
func autoLogin(data: [String: Any], completion: @escaping (Error?) -> Void) {
nimManager.autoLogin(with: data) { error in
DispatchQueue.main.async {
completion(error)
}
}
}
func logout(completion: @escaping (Error?) -> Void) {
nimManager.logout { error in
DispatchQueue.main.async {
completion(error)
}
}
}
// MARK: - 状态查询
var isLogined: Bool {
return nimManager.isLogined()
}
var currentAccount: String? {
return nimManager.currentAccount()
}
var loginStatus: NIMSDKLoginStatus {
return nimManager.currentLoginStatus()
}
// MARK: - 消息管理
func sendTextMessage(_ text: String, to sessionId: String, completion: @escaping (Error?) -> Void) {
let message = nimManager.createTextMessage(text)
let session = NIMSession(session: sessionId, type: .p2P)
nimManager.send(message, to: session) { error in
DispatchQueue.main.async {
completion(error)
}
}
}
func sendCustomMessage(attachment: NIMCustomAttachment, to sessionId: String, completion: @escaping (Error?) -> Void) {
let message = nimManager.createCustomMessage(with: attachment)
let session = NIMSession(session: sessionId, type: .p2P)
nimManager.send(message, to: session) { error in
DispatchQueue.main.async {
completion(error)
}
}
}
var unreadMessageCount: Int {
return Int(nimManager.unreadMessageCount())
}
var allRecentSessions: [NIMRecentSession] {
return nimManager.allRecentSessions() ?? []
}
// MARK: - 推送管理
func updateApnsToken(_ deviceToken: Data) {
nimManager.updateApnsToken(deviceToken)
}
func handlePushNotification(_ userInfo: [AnyHashable: Any]) -> Bool {
return nimManager.handlePushNotification(userInfo)
}
// MARK: - 私有方法
private func handleLoginStatusChange(_ status: NIMSDKLoginStatus) {
switch status {
case .notLogin:
print("未登录")
case .logging:
print("登录中")
case .logined:
print("已登录")
case .logout:
print("已登出")
case .kickout:
print("被踢出")
case .autoLoginFailed:
print("自动登录失败")
@unknown default:
print("未知状态")
}
}
}
// MARK: - Swift 扩展
extension NIMSDKService {
// 创建礼物消息的便捷方法
func createGiftMessage(giftId: String, giftName: String, giftCount: Int) -> NIMMessage? {
let attachment = AttachmentModel()
attachment.first = Int32(CustomMessageType_Gift)
attachment.second = Int32(Custom_Message_Sub_Gift_Send)
attachment.data = [
"giftId": giftId,
"giftName": giftName,
"giftCount": giftCount
]
return nimManager.createCustomMessage(with: attachment)
}
// 发送礼物消息的便捷方法
func sendGift(giftId: String, giftName: String, giftCount: Int, to sessionId: String, completion: @escaping (Error?) -> Void) {
guard let message = createGiftMessage(giftId: giftId, giftName: giftName, giftCount: giftCount) else {
completion(NSError(domain: "NIMSDKService", code: -1, userInfo: [NSLocalizedDescriptionKey: "创建礼物消息失败"]))
return
}
let session = NIMSession(session: sessionId, type: .p2P)
nimManager.send(message, to: session) { error in
DispatchQueue.main.async {
completion(error)
}
}
}
}
```
### 3.3 Swift 视图控制器使用示例
```swift
import UIKit
class ChatViewController: UIViewController {
private let nimService = NIMSDKService.shared
override func viewDidLoad() {
super.viewDidLoad()
setupNIMSDK()
}
private func setupNIMSDK() {
// 检查登录状态
if !nimService.isLogined {
// 执行登录
nimService.login(account: "user123", token: "token123") { [weak self] error in
if let error = error {
print("登录失败: \(error)")
} else {
print("登录成功")
self?.startChat()
}
}
} else {
startChat()
}
}
private func startChat() {
// 开始聊天功能
print("开始聊天")
}
// MARK: - 发送消息示例
@IBAction func sendTextMessage(_ sender: UIButton) {
nimService.sendTextMessage("Hello from Swift!", to: "receiver123") { error in
if let error = error {
print("发送失败: \(error)")
} else {
print("发送成功")
}
}
}
@IBAction func sendGiftMessage(_ sender: UIButton) {
nimService.sendGift(giftId: "123", giftName: "玫瑰花", giftCount: 1, to: "receiver123") { error in
if let error = error {
print("发送礼物失败: \(error)")
} else {
print("发送礼物成功")
}
}
}
// MARK: - 获取消息信息
func updateUnreadCount() {
let count = nimService.unreadMessageCount
print("未读消息数: \(count)")
}
func loadRecentSessions() {
let sessions = nimService.allRecentSessions
print("会话数量: \(sessions.count)")
}
}
```
### 3.4 Swift AppDelegate 集成
```swift
import UIKit
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
private let nimService = NIMSDKService.shared
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// NIMSDK已经在NIMSDKService中初始化
// 这里可以添加其他初始化代码
return true
}
// MARK: - 推送处理
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
nimService.updateApnsToken(deviceToken)
}
func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
let handled = nimService.handlePushNotification(userInfo)
completionHandler(handled ? .newData : .noData)
}
}
```
## 4. 配置说明
### 4.1 环境配置
```objc
// DEBUG环境配置
#ifdef DEBUG
config.apnsCername = @"pikoDevelopPush";
config.enabledHttpsForInfo = NO;
config.enabledHttpsForMessage = NO;
#else
config.apnsCername = @"newPiko";
config.enabledHttpsForInfo = YES;
config.enabledHttpsForMessage = YES;
#endif
```
### 4.2 AppKey配置
```objc
// 从常量文件获取AppKey
config.appKey = KeyWithType(KeyType_NetEase);
// 或者直接设置
config.appKey = @"your_app_key_here";
```
### 4.3 推送配置
确保在项目中正确配置了APNS证书并在配置中设置正确的证书名称。
## 5. 最佳实践
### 5.1 初始化时机
- 在App启动时尽早初始化NIMSDK
- 确保在用户登录前完成初始化
### 5.2 错误处理
- 对所有异步操作添加错误处理
- 在UI线程中处理回调结果
### 5.3 内存管理
- 及时移除不需要的代理
- 避免循环引用
### 5.4 状态管理
- 监听登录状态变化
- 根据状态变化更新UI
### 5.5 Swift集成
- 使用桥接头文件正确导入Objective-C类
- 在Swift中创建便捷的包装方法
## 6. 常见问题
### 6.1 编译错误
**Q: 编译时提示找不到NIMSDK头文件**
A: 确保正确导入了NIMSDK框架并在桥接头文件中正确导入。
### 6.2 初始化失败
**Q: NIMSDK初始化失败**
A: 检查AppKey是否正确网络连接是否正常。
### 6.3 登录问题
**Q: 登录后立即被踢出**
A: 检查账号是否在其他设备登录或者Token是否过期。
### 6.4 Swift桥接问题
**Q: Swift中无法使用NIMSDKManager**
A: 确保在桥接头文件中正确导入了相关头文件。
### 6.5 推送问题
**Q: 推送通知无法接收**
A: 检查APNS证书配置确保设备Token正确上传。
---
**文档版本**: 1.0
**最后更新**: 2024年12月
**维护人员**: 开发团队

View File

@@ -1,559 +0,0 @@
# NIMSDK 集成说明文档
## 目录
- [1. 项目概述](#1-项目概述)
- [2. NIMSDK导入配置](#2-nimsdk导入配置)
- [3. NIMSDK初始化流程](#3-nimsdk初始化流程)
- [4. 关键配置参数说明](#4-关键配置参数说明)
- [5. 自定义消息处理](#5-自定义消息处理)
- [6. 登录管理](#6-登录管理)
- [7. 消息接收处理](#7-消息接收处理)
- [8. 推送通知集成](#8-推送通知集成)
- [9. 最佳实践](#9-最佳实践)
- [10. 常见问题](#10-常见问题)
## 1. 项目概述
### 1.1 项目简介
YuMi是一个基于iOS平台的社交应用使用Objective-C开发采用MVP架构模式。项目集成了网易云信SDKNIMSDK用于实现即时通讯功能。
### 1.2 技术栈
- **开发语言**: Objective-C
- **架构模式**: MVP (Model-View-Presenter)
- **即时通讯**: 网易云信 NIMSDK_LITE
- **依赖管理**: CocoaPods
- **最低支持版本**: iOS 11.0
### 1.3 主要功能模块
- 用户登录注册
- 即时消息通讯
- 聊天室功能
- 动态发布
- 个人中心
- 房间直播
## 2. NIMSDK导入配置
### 2.1 Podfile配置
在项目的`Podfile`中添加NIMSDK依赖
```ruby
# 云信SDK
pod 'NIMSDK_LITE'
```
### 2.2 头文件导入
在需要使用NIMSDK的文件中导入头文件
```objc
#import <NIMSDK/NIMSDK.h>
```
### 2.3 项目结构
```
YuMi/
├── Appdelegate/
│ ├── AppDelegate.m # 主应用代理
│ └── AppDelegate+ThirdConfig.m # 第三方SDK配置
├── Modules/
│ ├── YMMessage/ # 消息模块
│ │ └── Tool/
│ │ └── CustomAttachmentDecoder # 自定义消息解码器
│ ├── YMTabbar/ # 主界面模块
│ └── YMRoom/ # 房间模块
└── Global/
└── YUMIConstant.h # 常量定义
```
## 3. NIMSDK初始化流程
### 3.1 初始化时序图
```mermaid
sequenceDiagram
participant App as AppDelegate
participant ThirdConfig as AppDelegate+ThirdConfig
participant NIMSDK as NIMSDK
participant Config as NIMSDKConfig
participant Decoder as CustomAttachmentDecoder
App->>ThirdConfig: initThirdConfig()
ThirdConfig->>ThirdConfig: configNIMSDK()
ThirdConfig->>NIMSDK: registerWithOption(option)
ThirdConfig->>Decoder: registerCustomDecoder()
ThirdConfig->>Config: shouldConsiderRevokedMessageUnreadCount = YES
ThirdConfig->>Config: setShouldSyncStickTopSessionInfos(YES)
ThirdConfig->>Config: enabledHttpsForInfo = NO (DEBUG)
ThirdConfig->>Config: enabledHttpsForMessage = NO (DEBUG)
Note over App: 应用启动完成
App->>App: loadMainPage()
App->>App: 检查登录状态
App->>NIMSDK: 自动登录或手动登录
```
### 3.2 初始化代码实现
#### 3.2.1 主应用代理初始化
```objc
// AppDelegate.m
- (BOOL)application:(UIApplication *)application
didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
// 初始化第三方SDK配置
[self initThirdConfig];
// 其他初始化代码...
return YES;
}
```
#### 3.2.2 NIMSDK配置方法
```objc
// AppDelegate+ThirdConfig.m
- (void)configNIMSDK {
// 1. 获取云信AppKey
NSString *appKey = KeyWithType(KeyType_NetEase);
NIMSDKOption *option = [NIMSDKOption optionWithAppKey:appKey];
// 2. 配置APNS证书名称
#ifdef DEBUG
option.apnsCername = @"pikoDevelopPush";
#else
option.apnsCername = @"newPiko";
#endif
// 3. 注册SDK
[[NIMSDK sharedSDK] registerWithOption:option];
// 4. 注册自定义消息解码器
[NIMCustomObject registerCustomDecoder:[[CustomAttachmentDecoder alloc] init]];
// 5. 配置SDK参数
[NIMSDKConfig sharedConfig].shouldConsiderRevokedMessageUnreadCount = YES;
[[NIMSDKConfig sharedConfig] setShouldSyncStickTopSessionInfos:YES];
// 6. DEBUG模式下禁用HTTPS
#ifdef DEBUG
[NIMSDKConfig sharedConfig].enabledHttpsForInfo = NO;
[NIMSDKConfig sharedConfig].enabledHttpsForMessage = NO;
#endif
}
```
## 4. 关键配置参数说明
### 4.1 AppKey配置
项目支持多环境配置,通过`YUMIConstant.m`中的`KeyWithType`方法获取:
```objc
// 测试环境
@(KeyType_NetEase): @"79bc37000f4018a2a24ea9dc6ca08d32"
// 生产环境
@(KeyType_NetEase): @"7371d729710cd6ce3a50163b956b5eb6"
```
### 4.2 APNS推送配置
```objc
// 开发环境
option.apnsCername = @"pikoDevelopPush";
// 生产环境
option.apnsCername = @"newPiko";
```
### 4.3 SDK配置参数详解
| 参数 | 值 | 说明 |
|------|----|----|
| `shouldConsiderRevokedMessageUnreadCount` | `YES` | 撤回消息计入未读数 |
| `shouldSyncStickTopSessionInfos` | `YES` | 同步置顶会话信息 |
| `enabledHttpsForInfo` | `NO` (DEBUG) | DEBUG模式禁用HTTPS信息传输 |
| `enabledHttpsForMessage` | `NO` (DEBUG) | DEBUG模式禁用HTTPS消息传输 |
## 5. 自定义消息处理
### 5.1 自定义附件解码器
#### 5.1.1 解码器接口定义
```objc
// CustomAttachmentDecoder.h
@interface CustomAttachmentDecoder : NSObject<NIMCustomAttachmentCoding>
@end
```
#### 5.1.2 解码器实现
```objc
// CustomAttachmentDecoder.m
- (id<NIMCustomAttachment>)decodeAttachment:(NSString *)content {
id<NIMCustomAttachment> attachment;
NSData *data = [content dataUsingEncoding:NSUTF8StringEncoding];
if (data) {
NSDictionary *dict = [NSJSONSerialization JSONObjectWithData:data
options:0
error:nil];
if ([dict isKindOfClass:[NSDictionary class]]) {
int first = [dict[@"first"] intValue];
int second = [dict[@"second"] intValue];
id originalData = dict[@"data"];
AttachmentModel *model = [[AttachmentModel alloc] init];
model.first = (short)first;
model.second = (short)second;
model.data = originalData;
attachment = model;
}
}
return attachment;
}
```
### 5.2 消息类型定义
自定义消息通过`AttachmentModel`定义结构:
```objc
@interface AttachmentModel : NSObject<NIMCustomAttachment>
@property (nonatomic, assign) short first; // 消息类型标识
@property (nonatomic, assign) short second; // 消息子类型标识
@property (nonatomic, strong) id data; // 消息数据内容
@end
```
### 5.3 消息类型示例
```objc
// 打招呼消息
if (attachment.first == CustomMessageType_FindNew &&
attachment.second == Custom_Message_Find_New_Greet_New_User) {
// 处理新用户打招呼消息
FindNewGreetMessageModel *greetInfo = [FindNewGreetMessageModel modelWithDictionary:attachment.data];
// 显示打招呼弹窗
}
```
## 6. 登录管理
### 6.1 登录状态检查
```objc
// 检查是否已登录
if ([NIMSDK sharedSDK].loginManager.isLogined) {
// 已登录,执行相关操作
} else {
// 未登录,跳转登录页面
}
```
### 6.2 自动登录处理
```objc
// NIMLoginManagerDelegate
- (void)onAutoLoginFailed:(NSError *)error {
// 如果非上次登录设备 autoLogin 会返回 417
if (error.code == 417) {
@weakify(self);
AccountModel* accountModel = [AccountInfoStorage instance].getCurrentAccountInfo;
[[NIMSDK sharedSDK].loginManager login:accountModel.uid
token:accountModel.netEaseToken
completion:^(NSError * _Nullable error) {
if (error) {
@strongify(self);
[self.presenter logout];
}
}];
return;
}
[self.presenter logout];
}
```
### 6.3 踢出处理
```objc
// NIMLoginManagerDelegate
- (void)onKickout:(NIMLoginKickoutResult *)result {
// 显示踢出提示
[XNDJTDDLoadingTool showErrorWithMessage:YMLocalizedString(@"TabbarViewController0")];
// 清理房间状态
if ([XPRoomMiniManager shareManager].getRoomInfo) {
[[RtcManager instance] exitRoom];
[[NIMSDK sharedSDK].chatroomManager exitChatroom:roomId completion:nil];
[self.roomMineView hiddenRoomMiniView];
}
// 执行登出
[self.presenter logout];
}
```
### 6.4 手动登录
```objc
// 执行登录
[[NIMSDK sharedSDK].loginManager login:accountModel.uid
token:accountModel.netEaseToken
completion:^(NSError * _Nullable error) {
if (error) {
// 登录失败处理
NSLog(@"登录失败: %@", error);
} else {
// 登录成功处理
NSLog(@"登录成功");
}
}];
```
## 7. 消息接收处理
### 7.1 消息接收代理
```objc
// NIMChatManagerDelegate
- (void)onRecvMessages:(NSArray<NIMMessage *> *)messages {
if ([AccountInfoStorage instance].getTicket.length == 0) {
return;
}
for (NIMMessage *message in messages) {
if (message.session.sessionType == NIMSessionTypeP2P) {
if (message.messageType == NIMMessageTypeCustom) {
NIMCustomObject *obj = (NIMCustomObject *)message.messageObject;
if (obj.attachment != nil && [obj.attachment isKindOfClass:[AttachmentModel class]]) {
AttachmentModel *attachment = (AttachmentModel *)obj.attachment;
// 处理自定义消息
[self handleCustomMessage:attachment];
}
}
}
}
}
```
### 7.2 自定义消息处理
```objc
- (void)handleCustomMessage:(AttachmentModel *)attachment {
switch (attachment.first) {
case CustomMessageType_FindNew:
[self handleFindNewMessage:attachment];
break;
case CustomMessageType_Gift:
[self handleGiftMessage:attachment];
break;
default:
break;
}
}
- (void)handleFindNewMessage:(AttachmentModel *)attachment {
if (attachment.second == Custom_Message_Find_New_Greet_New_User) {
FindNewGreetMessageModel *greetInfo = [FindNewGreetMessageModel modelWithDictionary:attachment.data];
if (greetInfo.uid.integerValue != [AccountInfoStorage instance].getUid.integerValue) {
// 显示打招呼弹窗
[self showGreetAlert:greetInfo];
}
}
}
```
### 7.3 广播消息处理
```objc
// NIMBroadcastManagerDelegate
- (void)onReceiveBroadcastMessage:(NIMBroadcastMessage *)broadcastMessage {
if ([AccountInfoStorage instance].getUid.length == 0) {
return;
}
// 处理广播消息
NSString *content = broadcastMessage.content;
// 解析并处理广播内容
}
```
## 8. 推送通知集成
### 8.1 推送权限申请
```objc
- (void)registerNot {
if (@available(iOS 10.0, *)) {
UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter];
[center requestAuthorizationWithOptions:(UNAuthorizationOptionAlert |
UNAuthorizationOptionBadge |
UNAuthorizationOptionSound)
completionHandler:^(BOOL granted, NSError * _Nullable error) {
if (granted) {
[center getNotificationSettingsWithCompletionHandler:^(UNNotificationSettings * _Nonnull settings) {
if (settings.authorizationStatus == UNAuthorizationStatusAuthorized) {
dispatch_async(dispatch_get_main_queue(), ^{
[[UIApplication sharedApplication] registerForRemoteNotifications];
});
}
}];
}
}];
}
}
```
### 8.2 设备Token更新
```objc
- (void)application:(UIApplication *)app
didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken {
// 上传devicetoken至云信服务器
[[NIMSDK sharedSDK] updateApnsToken:deviceToken];
}
```
### 8.3 推送消息处理
```objc
- (void)application:(UIApplication *)application
didReceiveRemoteNotification:(NSDictionary *)userInfo
fetchCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler {
NSString *data = userInfo[@"data"];
if (data) {
NSDictionary *dataDic = [data mj_JSONObject];
NSString *userId = dataDic[@"uid"];
if (userId) {
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)),
dispatch_get_main_queue(), ^{
[[NSNotificationCenter defaultCenter] postNotificationName:kOpenRoomNotification
object:nil
userInfo:@{@"type": @"kOpenChat",
@"uid": userId,
@"isNoAttention": @(YES)}];
ClientConfig *config = [ClientConfig shareConfig];
config.pushChatId = userId;
});
return;
}
}
completionHandler(UIBackgroundFetchResultNewData);
}
```
### 8.4 应用状态处理
```objc
- (void)applicationDidEnterBackground:(UIApplication *)application {
// 设置应用角标为未读消息数
NSInteger count = [NIMSDK sharedSDK].conversationManager.allUnreadCount;
[[UIApplication sharedApplication] setApplicationIconBadgeNumber:count];
}
- (void)applicationDidBecomeActive:(UIApplication *)application {
// 应用激活时清除角标
[[UIApplication sharedApplication] setApplicationIconBadgeNumber:0];
[[NSNotificationCenter defaultCenter] postNotificationName:@"kAppDidBecomeActive" object:nil];
}
```
## 9. 最佳实践
### 9.1 初始化最佳实践
1. **在应用启动时初始化**: 在`AppDelegate``didFinishLaunchingWithOptions`中调用
2. **配置环境参数**: 区分开发和生产环境的配置
3. **注册自定义解码器**: 在SDK注册后立即注册自定义解码器
4. **设置代理**: 在适当的时机添加和移除代理
### 9.2 登录管理最佳实践
1. **自动登录**: 优先使用自动登录,减少用户等待时间
2. **错误处理**: 对登录失败进行适当的错误处理和重试
3. **状态同步**: 保持本地登录状态与服务器状态同步
4. **踢出处理**: 妥善处理被踢出的情况,清理相关状态
### 9.3 消息处理最佳实践
1. **消息过滤**: 根据业务需求过滤不需要的消息
2. **自定义消息**: 合理设计自定义消息结构
3. **性能优化**: 避免在消息处理中进行耗时操作
4. **内存管理**: 及时释放不需要的消息对象
### 9.4 推送通知最佳实践
1. **权限申请**: 在合适的时机申请推送权限
2. **Token更新**: 及时更新设备Token
3. **消息解析**: 正确解析推送消息内容
4. **状态处理**: 根据应用状态处理推送消息
## 10. 常见问题
### 10.1 初始化问题
**Q: SDK初始化失败怎么办**
A: 检查AppKey是否正确网络连接是否正常证书配置是否正确。
**Q: 自定义解码器注册失败?**
A: 确保在SDK注册后注册解码器解码器类实现了正确的协议。
### 10.2 登录问题
**Q: 自动登录失败错误码417**
A: 这是正常情况,表示非上次登录设备,需要重新输入账号密码登录。
**Q: 登录后立即被踢出?**
A: 检查账号是否在其他设备登录或者Token是否过期。
### 10.3 消息问题
**Q: 自定义消息无法解析?**
A: 检查消息格式是否正确,解码器是否正确注册。
**Q: 消息发送失败?**
A: 检查网络连接,登录状态,以及消息内容格式。
### 10.4 推送问题
**Q: 推送通知无法接收?**
A: 检查推送权限是否开启证书配置是否正确设备Token是否正确上传。
**Q: 推送消息解析错误?**
A: 检查推送消息格式,确保解析逻辑正确。
## 11. 总结
本项目对NIMSDK的集成非常完整包括
1. **完整的初始化流程**: 从SDK注册到配置参数设置
2. **自定义消息处理**: 实现了自定义附件解码器
3. **登录状态管理**: 包含自动登录、踢出处理等
4. **推送通知集成**: 完整的APNS推送处理
5. **消息接收处理**: 支持自定义消息类型的处理
6. **多环境配置**: 区分开发和生产环境的配置
整个集成方案遵循了NIMSDK的最佳实践代码结构清晰功能完整为项目的即时通讯功能提供了坚实的基础。
---
**文档版本**: 1.0
**最后更新**: 2024年12月
**维护人员**: 开发团队

View File

@@ -1,262 +0,0 @@
# OAuth/Ticket 认证系统 API 文档
## 概述
本文档描述了 YuMi 应用中 OAuth 认证和 Ticket 会话管理的完整流程。系统采用两阶段认证机制:
1. **OAuth 阶段**:用户登录获取 `access_token`
2. **Ticket 阶段**:使用 `access_token` 获取业务会话 `ticket`
## 认证流程架构
### 核心组件
- **AccountInfoStorage**: 负责账户信息和 ticket 的本地存储
- **HttpRequestHelper**: 网络请求管理,自动添加认证头
- **Api+Login**: 登录相关 API 接口
- **Api+Main**: Ticket 获取相关 API 接口
### 认证数据模型
#### AccountModel
```objc
@interface AccountModel : PIBaseModel
@property (nonatomic, assign) NSString *uid; // 用户唯一标识
@property (nonatomic, copy) NSString *jti; // JWT ID
@property (nonatomic, copy) NSString *token_type; // Token 类型
@property (nonatomic, copy) NSString *refresh_token; // 刷新令牌
@property (nonatomic, copy) NSString *netEaseToken; // 网易云信令牌
@property (nonatomic, copy) NSString *access_token; // OAuth 访问令牌
@property (nonatomic, assign) NSNumber *expires_in; // 过期时间
@end
```
## API 接口详情
### 1. OAuth 登录接口
#### 1.1 手机验证码登录
```objc
+ (void)loginWithCode:(HttpRequestHelperCompletion)completion
phone:(NSString *)phone
code:(NSString *)code
client_secret:(NSString *)client_secret
version:(NSString *)version
client_id:(NSString *)client_id
grant_type:(NSString *)grant_type
phoneAreaCode:(NSString *)phoneAreaCode;
```
**接口路径**: `POST /oauth/token`
**请求参数**:
| 参数名 | 类型 | 必填 | 描述 |
|--------|------|------|------|
| phone | String | 是 | 手机号DES加密 |
| code | String | 是 | 验证码 |
| client_secret | String | 是 | 客户端密钥,固定值:"uyzjdhds" |
| version | String | 是 | 版本号,固定值:"1" |
| client_id | String | 是 | 客户端ID固定值"erban-client" |
| grant_type | String | 是 | 授权类型,验证码登录为:"sms_code" |
| phoneAreaCode | String | 是 | 手机区号 |
**返回数据**: AccountModel 对象
#### 1.2 手机密码登录
```objc
+ (void)loginWithPassword:(HttpRequestHelperCompletion)completion
phone:(NSString *)phone
password:(NSString *)password
client_secret:(NSString *)client_secret
version:(NSString *)version
client_id:(NSString *)client_id
grant_type:(NSString *)grant_type;
```
**接口路径**: `POST /oauth/token`
**请求参数**:
| 参数名 | 类型 | 必填 | 描述 |
|--------|------|------|------|
| phone | String | 是 | 手机号DES加密 |
| password | String | 是 | 密码DES加密 |
| client_secret | String | 是 | 客户端密钥 |
| version | String | 是 | 版本号 |
| client_id | String | 是 | 客户端ID |
| grant_type | String | 是 | 授权类型,密码登录为:"password" |
#### 1.3 第三方登录
```objc
+ (void)loginWithThirdPart:(HttpRequestHelperCompletion)completion
openid:(NSString *)openid
unionid:(NSString *)unionid
access_token:(NSString *)access_token
type:(NSString *)type;
```
**接口路径**: `POST /acc/third/login`
**请求参数**:
| 参数名 | 类型 | 必填 | 描述 |
|--------|------|------|------|
| openid | String | 是 | 第三方平台用户唯一标识 |
| unionid | String | 是 | 第三方平台联合ID |
| access_token | String | 是 | 第三方平台访问令牌 |
| type | String | 是 | 第三方平台类型1:Apple, 2:Facebook, 3:Google等 |
### 2. Ticket 获取接口
#### 2.1 获取 Ticket
```objc
+ (void)requestTicket:(HttpRequestHelperCompletion)completion
access_token:(NSString *)accessToken
issue_type:(NSString *)issueType;
```
**接口路径**: `POST /oauth/ticket`
**请求参数**:
| 参数名 | 类型 | 必填 | 描述 |
|--------|------|------|------|
| access_token | String | 是 | OAuth 登录获取的访问令牌 |
| issue_type | String | 是 | 签发类型,固定值:"multi" |
**返回数据**:
```json
{
"code": 200,
"data": {
"tickets": [
{
"ticket": "eyJhbGciOiJIUzI1NiJ9..."
}
]
}
}
```
### 3. HTTP 请求头配置
所有业务 API 请求都会自动添加以下请求头:
```objc
// 在 HttpRequestHelper 中自动配置
- (void)setupHeader {
AFHTTPSessionManager *client = [HttpRequestHelper requestManager];
// 用户ID头
if ([[AccountInfoStorage instance] getUid].length > 0) {
[client.requestSerializer setValue:[[AccountInfoStorage instance] getUid]
forHTTPHeaderField:@"pub_uid"];
}
// Ticket 认证头
if ([[AccountInfoStorage instance] getTicket].length > 0) {
[client.requestSerializer setValue:[[AccountInfoStorage instance] getTicket]
forHTTPHeaderField:@"pub_ticket"];
}
// 其他公共头
[client.requestSerializer setValue:[NSBundle uploadLanguageText]
forHTTPHeaderField:@"Accept-Language"];
[client.requestSerializer setValue:PI_App_Version
forHTTPHeaderField:@"App-Version"];
}
```
## 使用流程
### 完整登录流程示例
```objc
// 1. 用户登录获取 access_token
[Api loginWithCode:^(BaseModel * _Nullable data, NSInteger code, NSString * _Nullable msg) {
if (code == 200) {
// 保存账户信息
AccountModel *accountModel = [AccountModel modelWithDictionary:data.data];
[[AccountInfoStorage instance] saveAccountInfo:accountModel];
// 2. 使用 access_token 获取 ticket
[Api requestTicket:^(BaseModel * _Nullable data, NSInteger code, NSString * _Nullable msg) {
if (code == 200) {
NSArray *tickets = [data.data valueForKey:@"tickets"];
NSString *ticket = [tickets[0] valueForKey:@"ticket"];
// 保存 ticket
[[AccountInfoStorage instance] saveTicket:ticket];
// 3. 登录成功,可以进行业务操作
[self navigateToMainPage];
}
} access_token:accountModel.access_token issue_type:@"multi"];
}
} phone:encryptedPhone
code:verificationCode
client_secret:@"uyzjdhds"
version:@"1"
client_id:@"erban-client"
grant_type:@"sms_code"
phoneAreaCode:areaCode];
```
### 自动登录流程
```objc
- (void)autoLogin {
// 检查本地是否有账户信息
AccountModel *accountModel = [[AccountInfoStorage instance] getCurrentAccountInfo];
if (accountModel == nil || accountModel.access_token == nil) {
[self tokenInvalid]; // 跳转到登录页
return;
}
// 检查是否有有效的 ticket
if ([[AccountInfoStorage instance] getTicket].length > 0) {
[[self getView] autoLoginSuccess];
return;
}
// 使用 access_token 重新获取 ticket
[Api requestTicket:^(BaseModel * _Nonnull data) {
NSArray *tickets = [data.data valueForKey:@"tickets"];
NSString *ticket = [tickets[0] valueForKey:@"ticket"];
[[AccountInfoStorage instance] saveTicket:ticket];
[[self getView] autoLoginSuccess];
} fail:^(NSInteger code, NSString * _Nullable msg) {
[self logout]; // ticket 获取失败,重新登录
} access_token:accountModel.access_token issue_type:@"multi"];
}
```
## 错误处理
### 401 未授权错误
当接收到 401 状态码时,系统会自动处理:
```objc
// 在 HttpRequestHelper 中
if (response && response.statusCode == 401) {
failure(response.statusCode, YMLocalizedString(@"HttpRequestHelper7"));
// 通常需要重新登录
}
```
### Ticket 过期处理
- Ticket 过期时服务器返回 401 错误
- 客户端应该使用保存的 `access_token` 重新获取 ticket
- 如果 `access_token` 也过期,则需要用户重新登录
## 安全注意事项
1. **数据加密**: 敏感信息(手机号、密码)使用 DES 加密传输
2. **本地存储**:
- `access_token` 存储在文件系统中
- `ticket` 存储在内存中,应用重启需重新获取
3. **请求头**: 所有业务请求自动携带 `pub_uid``pub_ticket`
4. **错误处理**: 建立完善的 401 错误重试机制
## 相关文件
- `YuMi/Structure/MVP/Model/AccountInfoStorage.h/m` - 账户信息存储管理
- `YuMi/Modules/YMLogin/Api/Api+Login.h/m` - 登录相关接口
- `YuMi/Modules/YMTabbar/Api/Api+Main.h/m` - Ticket 获取接口
- `YuMi/Network/HttpRequestHelper.h/m` - 网络请求管理
- `YuMi/Structure/MVP/Model/AccountModel.h/m` - 账户数据模型

View File

@@ -1,247 +0,0 @@
# PublicRoomManager 使用指南
## 概述
`PublicRoomManager` 是一个常驻单例,负责管理用户进入公共聊天房间的逻辑。它会在用户登录成功后自动初始化,并在用户登出时自动清理。
## 主要功能
1. **自动初始化**: 用户登录成功后自动初始化
2. **自动进房**: 根据用户的分区ID自动进入对应的公共聊天房间
3. **消息监听**: 监听公共房间的消息
4. **自动清理**: 用户登出时自动退出房间并清理状态
5. **用户切换处理**: 支持多次登出-登录的重置情况
## 核心特性
### 1. 生命周期管理
```objc
// 初始化(在用户登录成功后自动调用)
[[PublicRoomManager sharedManager] initialize];
// 重置(在用户登出时自动调用)
[[PublicRoomManager sharedManager] reset];
```
### 2. 状态查询
```objc
// 检查是否已初始化
BOOL isInitialized = [[PublicRoomManager sharedManager] isInitialized];
// 检查是否已在公共房间中
BOOL isInPublicRoom = [[PublicRoomManager sharedManager] isInPublicRoom];
// 获取当前公共房间ID
NSString *roomId = [[PublicRoomManager sharedManager] currentPublicRoomId];
```
### 3. 手动控制
```objc
// 手动进入公共房间
[[PublicRoomManager sharedManager] enterPublicRoomWithCompletion:^(NSError * _Nullable error) {
if (error) {
NSLog(@"进入公共房间失败: %@", error);
} else {
NSLog(@"进入公共房间成功");
}
}];
// 手动退出公共房间
[[PublicRoomManager sharedManager] exitPublicRoomWithCompletion:^(NSError * _Nullable error) {
if (error) {
NSLog(@"退出公共房间失败: %@", error);
} else {
NSLog(@"退出公共房间成功");
}
}];
```
## 集成点
### 1. 登录流程集成
`PILoginManager.m` 的登录成功回调中添加:
```objc
// 初始化公共房间管理器
[[PublicRoomManager sharedManager] initialize];
```
### 2. 登出流程集成
`BaseMvpPresenter.m` 的 logout 方法中添加:
```objc
// 重置公共房间管理器
[[PublicRoomManager sharedManager] reset];
```
### 3. 用户信息更新集成
`TabbarViewController.m``getUserInfoSuccess` 方法中添加:
```objc
// 更新公共房间管理器的用户信息
[[PublicRoomManager sharedManager] updateUserInfo:userInfo];
```
### 4. 配置更新集成
`ClientConfig.m` 的配置加载完成后添加:
```objc
// 通知公共房间管理器配置已更新
[[PublicRoomManager sharedManager] updateConfig];
```
## 工作原理
### 1. 初始化流程
1. 检查用户是否已登录
2. 检查用户信息是否完整(包含 partitionId
3. 检查配置信息是否已加载(包含 publicChatRoomIdMap
4. 注册云信消息代理
5. 根据 partitionId 获取对应的 roomId
6. 进入公共聊天房间
### 2. 进房流程
1. 创建 NIMChatroomEnterRequest
2. 设置用户扩展信息(头像、昵称、等级等)
3. 调用云信 SDK 进入房间
4. 处理进房成功/失败回调
### 3. 消息处理
1. 实现 NIMChatManagerDelegate
2. 过滤公共房间消息
3. 处理消息内容
### 4. 清理流程
1. 退出公共聊天房间
2. 移除云信代理
3. 重置所有状态
## 配置要求
### 1. 用户信息要求
用户信息必须包含 `partitionId` 字段:
```objc
UserInfoModel *userInfo = [AccountInfoStorage instance].getHomeUserInfo;
NSString *partitionId = userInfo.partitionId; // 必须存在
```
### 2. 配置信息要求
配置信息必须包含 `publicChatRoomIdMap` 字段:
```objc
ClientDataModel *configInfo = [ClientConfig shareConfig].configInfo;
NSDictionary *publicChatRoomIdMap = configInfo.publicChatRoomIdMap; // 必须存在
```
`publicChatRoomIdMap` 的格式应该是:
```json
{
"1": "roomId1", // 分区1对应的房间ID
"2": "roomId2", // 分区2对应的房间ID
"3": "roomId3" // 分区3对应的房间ID
}
```
## 错误处理
### 1. 初始化失败
- 用户未登录:等待用户登录
- 用户信息不完整:等待用户信息更新
- 配置信息未加载:等待配置更新
### 2. 进房失败
- 网络错误:记录日志,可重试
- 房间不存在:记录日志,跳过
- 权限不足:记录日志,跳过
### 3. 用户切换
- 检测到用户ID变化时自动重置
- 清理旧用户状态
- 重新初始化新用户
## 日志输出
PublicRoomManager 会输出详细的日志信息:
```
PublicRoomManager: 初始化成功用户ID: 123456, 分区ID: 1
PublicRoomManager: 尝试进入公共房间分区ID: 1, 房间ID: roomId1
PublicRoomManager: 进入公共房间成功房间ID: roomId1
PublicRoomManager: 收到公共房间消息: Hello World
PublicRoomManager: 检测到用户切换,重置管理器
PublicRoomManager: 开始重置
PublicRoomManager: 退出公共房间成功
PublicRoomManager: 重置完成
```
## 注意事项
1. **线程安全**: 所有操作都在主线程执行
2. **内存管理**: 使用单例模式,避免内存泄漏
3. **错误恢复**: 支持自动重试和错误恢复
4. **状态同步**: 确保状态与实际云信状态同步
5. **性能优化**: 避免重复初始化和不必要的操作
## 扩展功能
### 1. 消息处理扩展
可以在 `onRecvMessages` 方法中添加自定义的消息处理逻辑:
```objc
- (void)onRecvMessages:(NSArray<NIMMessage *> *)messages {
for (NIMMessage *message in messages) {
if (message.session.sessionType == NIMSessionTypeChatroom) {
NSString *sessionId = message.session.sessionId;
if ([sessionId isEqualToString:self.currentPublicRoomId]) {
// 添加自定义消息处理逻辑
[self handlePublicRoomMessage:message];
}
}
}
}
```
### 2. 状态监听扩展
可以添加状态变化的通知:
```objc
// 在状态变化时发送通知
[[NSNotificationCenter defaultCenter] postNotificationName:@"PublicRoomManagerStateChanged"
object:nil
userInfo:@{@"isInPublicRoom": @(self.isInPublicRoom)}];
```
### 3. 重试机制扩展
可以添加更复杂的重试逻辑:
```objc
- (void)retryEnterPublicRoom {
if (self.retryCount < 3) {
self.retryCount++;
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
[self tryEnterPublicRoom];
});
}
}
```

View File

@@ -1,92 +0,0 @@
# 公共房间消息转发功能实现
## 功能概述
实现了从 PublicRoomManager 转发特定消息到房间中的功能。当 PublicRoomManager 接收到 attachment.first 为 106 的消息时,会自动转发到当前活跃的房间中。
## 实现方案
### 1. 通知机制
- 使用 NSNotificationCenter 进行消息转发
- 通知名称:`@"MessageFromPublicRoomWithAttachmentNotification"`
- 通知对象NIMMessage 对象
### 2. 修改的文件
#### PublicRoomManager.m
-`onRecvMessages:` 方法中添加转发逻辑
- 当检测到 `attachment.first == 106` 时发送通知
#### XPRoomViewController.m
-`setupNotifications` 方法中注册通知监听
- 添加 `handlePublicRoomMessageForward:` 方法处理转发的消息
-`dealloc` 中自动移除通知监听
#### YUMIConstant.m
- 添加常量定义:`kMessageFromPublicRoomWithAttachmentNotification`(已添加但当前使用字符串字面量)
#### XPRoomViewController.h
- 添加常量声明(已添加但当前使用字符串字面量)
## 使用流程
1. **消息接收**PublicRoomManager 接收到公共房间消息
2. **类型检查**:检查 attachment.first 是否为 106
3. **发送通知**:如果是 106 类型,发送转发通知
4. **接收处理**XPRoomViewController 接收通知并处理
5. **消息显示**:通过现有的消息处理流程显示在房间中
## 代码示例
### 发送通知PublicRoomManager.m
```objective-c
if (attachment && attachment.first == 106) {
[[NSNotificationCenter defaultCenter] postNotificationName:@"MessageFromPublicRoomWithAttachmentNotification"
object:message];
NSLog(@"PublicRoomManager: 转发106类型消息到房间");
}
```
### 接收处理XPRoomViewController.m
```objective-c
- (void)handlePublicRoomMessageForward:(NSNotification *)notification {
NIMMessage *message = notification.object;
if (![message isKindOfClass:[NIMMessage class]]) {
return;
}
// 检查房间是否处于活跃状态
if (!self.roomInfo || !self.messageContainerView) {
return;
}
// 使用现有的消息处理流程
[self.messageContainerView handleNIMCustomMessage:message];
}
```
## 测试场景
1. **正常转发**公共房间收到106类型消息时正确转发
2. **房间状态**:房间最小化、关闭等状态下的处理
3. **消息过滤**:确保转发的消息经过正确的过滤流程
4. **性能影响**:确保不影响现有消息处理性能
## 注意事项
1. 消息会经过现有的 `isCanDisplayMessage` 过滤
2. 支持最小化房间的特殊处理
3. 自动处理内存管理(在 dealloc 中移除监听)
4. 包含完整的错误检查和日志记录
## 扩展性
如果将来需要转发其他类型的消息,可以:
1. 修改条件判断(如 `attachment.first == 107`
2. 或者使用更通用的通知名称,在通知数据中携带消息类型信息

View File

@@ -1,293 +0,0 @@
# getUserInfo API 使用说明文档
## 方法概述
`getUserInfo:uid:``Api` 类中的一个静态方法,用于获取指定用户的详细信息。
## 方法签名
```objc
+ (void)getUserInfo:(HttpRequestHelperCompletion)completion uid:(NSString *)uid;
```
## 参数说明
### 输入参数
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| completion | HttpRequestHelperCompletion | 是 | 请求完成后的回调函数 |
| uid | NSString | 是 | 要查询的用户ID |
### 回调函数格式
```objc
typedef void(^HttpRequestHelperCompletion)(BaseModel* _Nullable data, NSInteger code, NSString * _Nullable msg);
```
**回调参数说明:**
- `data`: BaseModel 对象,包含服务器返回的数据
- `code`: NSIntegerHTTP 状态码或业务状态码
- `msg`: NSString服务器返回的消息或错误信息
## 实现原理
### 1. API 端点
- **Base64 编码的路径**: `dXNlci9nZXQ=`
- **解码后的路径**: `user/get`
- **请求方法**: GET
### 2. 请求流程
1. 将用户ID作为参数传递给 `makeRequest` 方法
2. `makeRequest` 方法通过 `__FUNCTION__` 宏自动解析参数名
3. 构造请求参数字典:`@{@"uid": uid}`
4. 调用 `HttpRequestHelper` 发送 GET 请求
### 3. 参数自动映射
该方法使用了特殊的参数映射机制:
- 通过 `__FUNCTION__` 宏获取方法名
- 解析方法名中的参数部分(冒号后的部分)
- 自动将传入的参数值与参数名对应
## 使用示例
### 基本用法
```objc
// 获取用户ID为 "12345" 的用户信息
[Api getUserInfo:^(BaseModel *data, NSInteger code, NSString *msg) {
if (code == 200) {
// 请求成功
NSLog(@"用户信息: %@", data.data);
NSLog(@"消息: %@", msg);
} else {
// 请求失败
NSLog(@"错误码: %ld", (long)code);
NSLog(@"错误信息: %@", msg);
}
} uid:@"12345"];
```
### 错误处理示例
```objc
[Api getUserInfo:^(BaseModel *data, NSInteger code, NSString *msg) {
switch (code) {
case 200:
// 成功获取用户信息
[self handleUserInfoSuccess:data.data];
break;
case 404:
// 用户不存在
[self showUserNotFoundAlert];
break;
case 401:
// 未授权访问
[self handleUnauthorizedAccess];
break;
default:
// 其他错误
[self showErrorAlert:msg];
break;
}
} uid:userId];
```
### 在 ViewController 中使用
```objc
@interface UserProfileViewController ()
@property (nonatomic, strong) NSString *currentUserId;
@end
@implementation UserProfileViewController
- (void)loadUserInfo {
if (!self.currentUserId) {
NSLog(@"用户ID不能为空");
return;
}
[Api getUserInfo:^(BaseModel *data, NSInteger code, NSString *msg) {
dispatch_async(dispatch_get_main_queue(), ^{
if (code == 200) {
[self updateUIWithUserInfo:data.data];
} else {
[self showErrorWithMessage:msg];
}
});
} uid:self.currentUserId];
}
- (void)updateUIWithUserInfo:(id)userInfo {
// 更新UI显示用户信息
// userInfo 的具体结构需要根据后端返回的数据格式来确定
}
- (void)showErrorWithMessage:(NSString *)message {
UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"错误"
message:message
preferredStyle:UIAlertControllerStyleAlert];
[alert addAction:[UIAlertAction actionWithTitle:@"确定" style:UIAlertActionStyleDefault handler:nil]];
[self presentViewController:alert animated:YES completion:nil];
}
@end
```
## 返回数据结构
### BaseModel 结构
```objc
@interface BaseModel : NSObject
@property(nonatomic,assign) long timestamp; // 时间戳
@property (nonatomic , strong) id data; // 返回的数据
@property (nonatomic , assign) NSInteger code; // 状态码
@property (nonatomic , copy) NSString *message; // 消息
@property (nonatomic,assign) long long cancelDate; // 注销时间戳
@property (nonatomic,copy) NSString *date; // 日期
@property (nonatomic,copy) NSString *reason; // 封禁理由
@end
```
### 成功响应示例
```json
{
"code": 200,
"message": "success",
"timestamp": 1640995200000,
"data": {
"uid": "12345",
"nickname": "用户昵称",
"avatar": "头像URL",
"level": 10,
"vip": false
// 其他用户信息字段...
}
}
```
### 错误响应示例
```json
{
"code": 404,
"message": "用户不存在",
"timestamp": 1640995200000,
"data": null
}
```
## 注意事项
### 1. 线程安全
- 回调函数在后台线程执行
- UI 更新操作需要在主线程进行
### 2. 参数验证
- 确保 `uid` 参数不为空
- 建议在使用前验证 `uid` 的格式
### 3. 内存管理
- 避免在回调中造成循环引用
- 使用 `__weak` 修饰符防止内存泄漏
### 4. 网络状态
- 建议在调用前检查网络连接状态
- 处理网络超时和连接失败的情况
## 最佳实践
### 1. 参数验证
```objc
- (void)getUserInfoWithValidation:(NSString *)uid {
if (!uid || uid.length == 0) {
NSLog(@"用户ID不能为空");
return;
}
// 验证UID格式根据实际需求调整
if (![self isValidUID:uid]) {
NSLog(@"用户ID格式不正确");
return;
}
[Api getUserInfo:^(BaseModel *data, NSInteger code, NSString *msg) {
// 处理响应
} uid:uid];
}
- (BOOL)isValidUID:(NSString *)uid {
// 根据实际业务需求验证UID格式
return uid.length > 0;
}
```
### 2. 错误处理
```objc
- (void)handleApiError:(NSInteger)code message:(NSString *)msg {
switch (code) {
case 200:
// 成功
break;
case 400:
NSLog(@"请求参数错误: %@", msg);
break;
case 401:
NSLog(@"未授权访问,需要重新登录");
[self redirectToLogin];
break;
case 404:
NSLog(@"用户不存在: %@", msg);
break;
case 500:
NSLog(@"服务器内部错误: %@", msg);
break;
default:
NSLog(@"未知错误 (code: %ld): %@", (long)code, msg);
break;
}
}
```
### 3. 缓存策略
```objc
- (void)getUserInfoWithCache:(NSString *)uid {
// 先检查本地缓存
UserInfo *cachedUser = [self getCachedUserInfo:uid];
if (cachedUser) {
[self updateUIWithUserInfo:cachedUser];
}
// 从服务器获取最新数据
[Api getUserInfo:^(BaseModel *data, NSInteger code, NSString *msg) {
if (code == 200) {
// 更新缓存
[self cacheUserInfo:data.data forUID:uid];
[self updateUIWithUserInfo:data.data];
} else {
// 如果服务器请求失败,使用缓存数据(如果有)
if (!cachedUser) {
[self showErrorWithMessage:msg];
}
}
} uid:uid];
}
```
## 相关方法
- `getUserInfos:uids:` - 批量获取多个用户信息
- `completeUserInfo:userInfo:` - 补全用户资料
- `getUserWalletInfo:uid:ticket:` - 获取用户钱包信息
## 版本信息
- **iOS 最低版本**: iOS 15.6
- **创建时间**: 2021/9/6
- **最后更新**: 当前版本

View File

@@ -0,0 +1,194 @@
# 礼物动画优化方案B实施总结
## 问题描述
**核心问题**当用户在送礼时combo其他用户也在送礼礼物动画从位置a移动到位置b将不会显示。
**触发条件**
- 接收到云信消息,类型为 `Custom_Message_Sub_Gift_Send``Custom_Message_Sub_Gift_ChannelNotify`
- 多个用户同时送礼
- 当前用户处于combo状态
## 问题根本原因
**核心问题**`GiftAnimationManager` 中的 `shouldUseComboAnimationForSender` 判断逻辑在多用户并发场景下不准确导致其他用户的礼物动画被错误地当作combo动画处理从而不显示。
### 具体问题点:
1. **全局状态判断错误**原逻辑只检查全局combo状态没有区分不同用户
2. **时间窗口判断不精确**:没有为每个用户维护独立的送礼时间
3. **状态管理混乱**combo状态和动画状态没有正确分离
## 修复方案实施
### 第一步:修改 GiftAnimationManager.h - 添加新方法声明 ✅
```objc
// 🔧 新增Combo状态管理方法
- (void)setUserComboState:(BOOL)isCombo forUser:(NSString *)uid;
- (void)clearUserComboState:(NSString *)uid;
- (void)updateUserGiftTime:(NSString *)uid;
- (void)cleanupExpiredStates;
```
### 第二步:修改 GiftAnimationManager.m - 实现精确的combo状态管理 ✅
**新增属性**
```objc
// 🔧 新增Combo状态管理属性
@property (nonatomic, strong) NSMutableDictionary<NSString *, NSNumber *> *userComboStates;
@property (nonatomic, strong) NSMutableDictionary<NSString *, NSDate *> *userLastGiftTime;
@property (nonatomic, assign) NSTimeInterval comboTimeWindow;
```
**核心方法实现**
```objc
// 🔧 修改优化shouldUseComboAnimationForSender方法
- (BOOL)shouldUseComboAnimationForSender:(NSString *)uid {
if (!uid || uid.length == 0) {
return NO;
}
// 优先使用精确状态判断
BOOL isUserInCombo = [self.userComboStates[uid] boolValue];
if (isUserInCombo) {
BOOL isCurrentUser = [uid isEqualToString:[AccountInfoStorage instance].getUid];
NSLog(@"[Combo effect] 🎯 用户 %@ 处于combo状态是否当前用户: %@", uid, isCurrentUser ? @"YES" : @"NO");
return isCurrentUser;
}
// 兜底:时间窗口判断
NSDate *lastGiftTime = self.userLastGiftTime[uid];
if (lastGiftTime) {
NSTimeInterval timeSinceLastGift = [[NSDate date] timeIntervalSinceDate:lastGiftTime];
if (timeSinceLastGift <= self.comboTimeWindow) {
BOOL isCurrentUser = [uid isEqualToString:[AccountInfoStorage instance].getUid];
NSLog(@"[Combo effect] 🎯 用户 %@ 在时间窗口内,是否当前用户: %@", uid, isCurrentUser ? @"YES" : @"NO");
return isCurrentUser;
}
}
NSLog(@"[Combo effect] 🎯 用户 %@ 不使用combo动画", uid);
return NO;
}
```
### 第三步:修改 GiftComboManager.m - 添加状态通知机制 ✅
**新增方法**
```objc
// 🔧 新增:状态通知方法实现
- (void)setUserComboState:(BOOL)isCombo forUser:(NSString *)uid {
// 通过通知中心通知动画管理器
[[NSNotificationCenter defaultCenter] postNotificationName:@"GiftComboStateChanged"
object:nil
userInfo:@{
@"uid": uid,
@"isCombo": @(isCombo)
}];
}
```
### 第四步:修改 RoomAnimationView.m - 集成新的状态管理 ✅
**添加通知监听**
```objc
// 🔧 新增监听combo状态变化
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(handleComboStateChanged:)
name:@"GiftComboStateChanged"
object:nil];
```
**添加用户送礼时间更新**
```objc
// 🔧 新增:更新用户送礼时间
[self.giftAnimationManager updateUserGiftTime:receiveInfo.uid];
```
**添加状态处理方法**
```objc
// 🔧 新增处理combo状态变化
- (void)handleComboStateChanged:(NSNotification *)notification {
NSDictionary *userInfo = notification.userInfo;
NSString *uid = userInfo[@"uid"];
BOOL isCombo = [userInfo[@"isCombo"] boolValue];
// 通知动画管理器更新combo状态
if (isCombo) {
[self.giftAnimationManager setUserComboState:YES forUser:uid];
} else {
[self.giftAnimationManager clearUserComboState:uid];
}
}
```
### 第五步:添加清理机制 ✅
**在dealloc中添加清理**
```objc
// 🔧 新增清理combo状态管理
[self cleanupExpiredStates];
[self.userComboStates removeAllObjects];
[self.userLastGiftTime removeAllObjects];
```
## 修复效果
### 修复前的问题:
1. 用户A在combo状态时用户B送礼用户B的动画不显示
2. 全局状态判断导致所有用户的动画都被错误处理
3. 没有精确的用户状态管理
### 修复后的效果:
1. ✅ 每个用户有独立的combo状态管理
2. ✅ 精确判断当前用户是否应该使用combo动画
3. ✅ 其他用户的礼物动画正常显示
4. ✅ 添加了完整的日志记录,便于调试
5. ✅ 实现了自动清理机制,防止内存泄漏
## 技术要点
### 1. 精确状态管理
- 为每个用户维护独立的combo状态
- 使用时间窗口作为兜底判断机制
- 实现了状态自动清理
### 2. 通知机制
- 使用NSNotificationCenter实现组件间通信
- 解耦了GiftComboManager和GiftAnimationManager
- 保证了状态同步的及时性
### 3. 线程安全
- 使用串行队列处理动画
- 在主线程执行UI操作
- 避免了并发访问问题
### 4. 性能优化
- 使用字典存储用户状态O(1)查找
- 定期清理过期状态
- 最小化内存占用
## 测试建议
### 测试场景:
1. **单用户combo测试**验证当前用户的combo动画正常
2. **多用户并发测试**:验证其他用户的动画正常显示
3. **状态切换测试**验证combo状态的正确切换
4. **内存泄漏测试**:验证清理机制的有效性
### 验证方法:
1. 查看日志输出,确认状态判断正确
2. 观察动画显示效果
3. 使用Instruments检查内存使用
## 总结
通过实施精确的用户状态管理、通知机制和清理机制,成功解决了多用户并发送礼时动画不显示的问题。修复方案具有以下特点:
- **精确性**:每个用户独立的状态管理
- **可靠性**:完整的错误处理和日志记录
- **性能**:高效的数据结构和自动清理
- **可维护性**:清晰的代码结构和注释
该修复方案确保了礼物动画系统在多用户并发场景下的稳定性和正确性。

View File

@@ -0,0 +1,25 @@
## 当前状态
### ✅ 第一步:创建 StageViewManager 类
- [x] 创建 StageViewManager.h
- [x] 创建 StageViewManager.m
- [x] 实现基本的 stageView 管理逻辑
- [x] 使用 RoomInfoModel 中的 RoomType 定义
### ✅ 第二步:在 XPRoomViewController.m 中集成
- [x] 添加 StageViewManager 的导入
- [x] 添加 StageViewManager 属性声明
- [x] 在 viewDidLoad 中初始化 StageViewManager
- [x] 添加 setupStageViewManager 方法
### 🔄 第三步:重构现有的 stageView 更新逻辑
- [ ] 找到所有 stageView 创建和更新的地方
- [ ] 替换为使用 StageViewManager
- [ ] 移除重复的 stageView 更新代码
- [ ] 测试确保功能正常
### ⏳ 第四步:测试和验证
- [ ] 测试不同房间类型的切换
- [ ] 验证 stageView 更新逻辑
- [ ] 性能测试
- [ ] 代码审查

View File

@@ -1,71 +0,0 @@
# micButton 状态表格
## micButton 可用状态总览
| 场景 | 用户状态 | micButton显示 | micButton可用性 | micState值 | 音频状态 | 备注 |
|------|----------|---------------|----------------|------------|----------|------|
| **用户上麦前** | 未在麦位 | 隐藏 | 不可用 | MICState_None | 无音频 | isOnMic = NO |
| **用户刚上麦** | 刚上麦位 | 显示 | 可用 | MICState_Close | 静音 | 默认静音状态localMuted = YES |
| **用户开麦** | 在麦位 | 显示开麦状态 | 可用 | MICState_Open | 开启音频 | 用户可以说话 |
| **用户关麦** | 在麦位 | 显示关麦状态 | 可用 | MICState_Close | 静音 | 用户无法说话 |
| **用户下麦** | 离开麦位 | 隐藏 | 不可用 | MICState_None | 无音频 | isOnMic = NO |
## 不同场景下的状态变化
### 1. 用户加入/离开舞台
| 操作 | micButton状态变化 | 音频状态变化 | UI更新 |
|------|------------------|--------------|--------|
| 用户上麦 | 隐藏 → 显示(关麦状态) | 无音频 → 静音 | isOnMic: NO → YES |
| 用户下麦 | 显示 → 隐藏 | 当前状态 → 无音频 | isOnMic: YES → NO |
### 2. 其他用户加入/离开舞台
| 操作 | 当前用户micButton | 影响范围 | 说明 |
|------|------------------|----------|------|
| 他人上麦 | 无变化 | 仅更新麦位显示 | micButton状态不受影响 |
| 他人下麦 | 无变化 | 仅更新麦位显示 | micButton状态不受影响 |
### 3. 房间最小化场景
| 状态 | micButton处理 | 音频处理 | 数据同步 |
|------|---------------|----------|----------|
| 最小化时 | 监听队列变化 | 继续广播音频 | selfNeedBroadcast基于MicroMicStateType_Open |
| 恢复显示 | recheckMicState同步 | 保持当前状态 | 从XPSkillCardPlayerManager.micState同步 |
## micButton 状态枚举详解
| MICState枚举 | 数值 | 含义 | UI表现 | 用户能否说话 |
|-------------|------|------|--------|-------------|
| MICState_None | 0 | 无麦克风状态 | micButton隐藏 | ❌ 否 |
| MICState_Close | 1 | 麦克风关闭 | 显示关麦图标 | ❌ 否 |
| MICState_Open | 2 | 麦克风开启 | 显示开麦图标 | ✅ 是 |
## 关键时序和同步机制
### 状态更新流程
```
用户操作 → StageView处理 → 麦位队列更新 → onMicroQueueUpdate回调
→ XPRoomViewController分发 → XPRoomMenuContainerView更新
→ micButton状态/显示更新 → recheckMicState同步检查
```
### 重要同步点
| 时机 | 同步操作 | 目的 |
|------|----------|------|
| viewWillAppear | recheckMicState | 确保UI与全局状态一致 |
| 房间退出 | micState = MICState_None | 重置状态 |
| 麦位变化 | onMicroQueueUpdate | 实时更新UI |
## 特殊情况处理
| 特殊情况 | micButton行为 | 处理逻辑 |
|----------|---------------|----------|
| 网络断线重连 | 重新同步状态 | recheckMicState确保一致性 |
| 被踢出麦位 | 立即隐藏 | NIMChatroomEventTypeKicked触发 |
| 房间模式切换 | 根据新模式调整 | 不同RoomModeType有不同处理 |
| 禁麦状态 | 显示但可能限制功能 | isNoProhibitMic控制 |
---
**总结**: micButton的可用状态主要取决于用户是否在麦位(isOnMic)在麦位时根据MICState显示不同状态用户只有在MICState_Open时才能说话。

View File

@@ -1,63 +0,0 @@
sequenceDiagram
participant User as
participant StageView as StageView
participant RoomVC as XPRoomViewController
participant NIM as NIM SDK
participant API as Api服务
participant MenuView as XPRoomMenuContainerView
Note over User,MenuView: mic完整流程
%%
User->>StageView:
StageView->>StageView: microViewTapped(didSelectAtIndex)
%%
StageView->>StageView: (//)
alt
StageView->>StageView: displayUserCard()
else
StageView->>RoomVC:
%%
alt /
StageView->>StageView: (///)
User->>StageView: "上麦"
else
alt
StageView->>NIM: nimDownQueue()
NIM->>NIM:
end
end
%%
alt (RoomType_19Mic)6
StageView->>API: requestBossMicUp()
API-->>StageView:
end
%%
StageView->>NIM: nimUpQueue()
NIM->>NIM: (userInfoToQueueExt)
NIM->>API:
API-->>NIM:
%%
NIM->>RoomVC: onMicroQueueUpdate回调
RoomVC->>MenuView:
MenuView->>MenuView: micButton状态
MenuView->>MenuView: recheckMicState()
%% UI更新阶段
StageView->>StageView: UI显示
StageView->>StageView:
%% 广
NIM->>NIM: (CustomMessageType_Hall_Super_Admin)
NIM->>RoomVC: 广
else
StageView->>User: "麦位已锁定"
end
Note over User,MenuView: