diff --git a/YuMi/Modules/YMRoom/View/MessageContainerView/MsRoomMessageMainView.m b/YuMi/Modules/YMRoom/View/MessageContainerView/MsRoomMessageMainView.m index e4c6ce21..047de7f8 100644 --- a/YuMi/Modules/YMRoom/View/MessageContainerView/MsRoomMessageMainView.m +++ b/YuMi/Modules/YMRoom/View/MessageContainerView/MsRoomMessageMainView.m @@ -112,15 +112,21 @@ #pragma mark - RoomGuestDelegate - (void)handleNIMCustomMessage:(NIMMessage *)message { - [self.messageListView handleNIMCustomMessage:message]; + dispatch_async(dispatch_get_main_queue(), ^{ + [self.messageListView handleNIMCustomMessage:message]; + }); } - (void)handleNIMNotificationMessage:(NIMMessage *)message { - [self.messageListView handleNIMNotificationMessage:message]; + dispatch_async(dispatch_get_main_queue(), ^{ + [self.messageListView handleNIMNotificationMessage:message]; + }); } - (void)handleNIMTextMessage:(NIMMessage *)message { - [self.messageListView handleNIMTextMessage:message]; + dispatch_async(dispatch_get_main_queue(), ^{ + [self.messageListView handleNIMTextMessage:message]; + }); } - (void)handleNIMImageMessage:(NIMMessage *)message { @@ -129,16 +135,22 @@ - (void)onRoomMiniEntered { self.hidden = NO; - [self.messageListView onRoomMiniEntered]; + dispatch_async(dispatch_get_main_queue(), ^{ + [self.messageListView onRoomMiniEntered]; + }); } - (void)onRoomEntered { self.hidden = NO; - [self.messageListView onRoomEntered]; + dispatch_async(dispatch_get_main_queue(), ^{ + [self.messageListView onRoomEntered]; + }); } - (void)onRoomUpdate { - [self.messageListView onRoomUpdate]; + dispatch_async(dispatch_get_main_queue(), ^{ + [self.messageListView onRoomUpdate]; + }); } #pragma mark - XPRoomMessageContainerViewDelegate - (void)xPRoomMessageContainerViewlDidTapEmpty:(XPRoomMessageContainerView *)view{ @@ -165,7 +177,9 @@ } } - [self.messageListView handleBroadcastMessage:broadcastMessage]; + dispatch_async(dispatch_get_main_queue(), ^{ + [self.messageListView handleBroadcastMessage:broadcastMessage]; + }); } #pragma mark - 懒加载 diff --git a/YuMi/Modules/YMRoom/View/MessageContainerView/XPRoomMessageContainerView.m b/YuMi/Modules/YMRoom/View/MessageContainerView/XPRoomMessageContainerView.m index ab8c3fb0..333623b3 100644 --- a/YuMi/Modules/YMRoom/View/MessageContainerView/XPRoomMessageContainerView.m +++ b/YuMi/Modules/YMRoom/View/MessageContainerView/XPRoomMessageContainerView.m @@ -93,6 +93,13 @@ NSString * const kRoomShowTopicKey = @"kRoomShowTopicKey"; /// 清空公屏后下次追加强制走 reload,避免插入动画与数据源不一致 @property (nonatomic, assign) BOOL forceReloadNextAppend; +/// 视图是否已经完成首次布局,未就绪期间的 UI 更新将排队 +@property (atomic, assign) BOOL viewReady; +/// 是否正在刷新,避免嵌套批量更新 +@property (atomic, assign) BOOL isFlushing; +/// 待执行的 UI 操作队列(在 viewReady 之前累积) +@property (nonatomic, strong) NSMutableArray *pendingOps; + @end @@ -115,6 +122,54 @@ NSString * const kRoomShowTopicKey = @"kRoomShowTopicKey"; return self; } +/// 统一的安全插入实现:在数据与 UI 不一致时回退为 reload,永不崩溃 +- (void)safeApplyInsertsFromStartIndex:(NSInteger)startIndex + newItemsCount:(NSInteger)newItemsCount + needReload:(BOOL)needReload { + if (needReload || newItemsCount <= 0) { + [self.messageTableView reloadData]; + return; + } + + if (self.isFlushing) { + // 合并到下一帧,避免嵌套更新 + dispatch_async(dispatch_get_main_queue(), ^{ + [self safeApplyInsertsFromStartIndex:startIndex newItemsCount:newItemsCount needReload:NO]; + }); + return; + } + + self.isFlushing = YES; + + // 校验一致性 + NSInteger beforeRows = [self.messageTableView numberOfRowsInSection:0]; + NSInteger expectedRows = [self getCurrentDataSourceCount]; + BOOL indexValid = (startIndex >= 0 && startIndex <= beforeRows); + BOOL countValid = (beforeRows + newItemsCount == expectedRows); + if (!indexValid || !countValid) { + [self.messageTableView reloadData]; + self.isFlushing = NO; + return; + } + + // 生成 indexPaths + NSMutableArray *indexPaths = [NSMutableArray array]; + for (NSInteger i = 0; i < newItemsCount; i++) { + [indexPaths addObject:[NSIndexPath indexPathForRow:startIndex + i inSection:0]]; + } + + // 原子提交 + [self.messageTableView beginUpdates]; + @try { + [self.messageTableView insertRowsAtIndexPaths:indexPaths withRowAnimation:UITableViewRowAnimationFade]; + } @catch (__unused NSException *e) { + [self.messageTableView reloadData]; + } @finally { + [self.messageTableView endUpdates]; + self.isFlushing = NO; + } +} + - (void)changeType:(NSInteger)type { if (self.displayType == type) { return; @@ -182,6 +237,19 @@ NSString * const kRoomShowTopicKey = @"kRoomShowTopicKey"; ///追加数据源 - (void)appendAndScrollToAtUser { + // 在未就绪时排队 + if (![NSThread isMainThread]) { + dispatch_async(dispatch_get_main_queue(), ^{ + [self appendAndScrollToAtUser]; + }); + return; + } + if (!self.viewReady) { + if (!self.pendingOps) self.pendingOps = [NSMutableArray array]; + __weak typeof(self) weakSelf = self; + [self.pendingOps addObject:^{ __strong typeof(weakSelf) self = weakSelf; [self appendAndScrollToAtUser]; }]; + return; + } // 1. 检查 incomingMessages 是否为空 if (self.incomingMessages.count < 1) { // 2. 安全检查 locationArray 是否为空 @@ -245,32 +313,8 @@ NSString * const kRoomShowTopicKey = @"kRoomShowTopicKey"; [self.incomingMessages removeAllObjects]; // 7. 更新 UITableView - if (needReloadData || self.forceReloadNextAppend) { - [self.messageTableView reloadData]; - self.forceReloadNextAppend = NO; - } 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]; - } - } - } + [self safeApplyInsertsFromStartIndex:currentRows newItemsCount:tempNewDatas.count needReload:(needReloadData || self.forceReloadNextAppend)]; + self.forceReloadNextAppend = NO; // 6. 滚动到指定位置或底部 [self scrollToFirstLocationOrBottom]; @@ -523,6 +567,10 @@ NSString * const kRoomShowTopicKey = @"kRoomShowTopicKey"; [self addSubview:self.messageTipsBtn]; [self addSubview:self.atTipBtn]; self.messageTableView.tableHeaderView = self.headerView; + + // 标记未就绪,等待首次布局完成后再 flush 队列 + self.viewReady = NO; + self.isFlushing = NO; } - (void)initSubViewConstraints { @@ -546,6 +594,18 @@ NSString * const kRoomShowTopicKey = @"kRoomShowTopicKey"; make.bottom.mas_equalTo(self.mas_bottom).offset(-5); make.leading.mas_equalTo(self); }]; + + // 首次约束完成后,标记就绪并 flush 排队操作 + dispatch_async(dispatch_get_main_queue(), ^{ + if (!self.viewReady) { + self.viewReady = YES; + if (self.pendingOps.count > 0) { + NSArray *ops = [self.pendingOps copy]; + [self.pendingOps removeAllObjects]; + for (void (^op)(void) in ops) { op(); } + } + } + }); } ///是否是当前房间 @@ -609,6 +669,19 @@ NSString * const kRoomShowTopicKey = @"kRoomShowTopicKey"; ///追加数据源 - (void)appendAndScrollToBottom { + // 在未就绪时排队 + if (![NSThread isMainThread]) { + dispatch_async(dispatch_get_main_queue(), ^{ + [self appendAndScrollToBottom]; + }); + return; + } + if (!self.viewReady) { + if (!self.pendingOps) self.pendingOps = [NSMutableArray array]; + __weak typeof(self) weakSelf = self; + [self.pendingOps addObject:^{ __strong typeof(weakSelf) self = weakSelf; [self appendAndScrollToBottom]; }]; + return; + } if (self.incomingMessages.count < 1) { ///滚动到底部(如果有人at自己后,income消息在点击at按钮处做了拼接处理,因为点击at按钮跳转到的是对应的at人消息,如果后面有其他消息时,点有更多按钮时需要滚动到最底部) [self scrollToBottom:YES]; @@ -646,32 +719,8 @@ NSString * const kRoomShowTopicKey = @"kRoomShowTopicKey"; [self updateAllDataSource:tempArray]; // 如果有删除操作或清空标记,使用 reloadData;否则使用增量更新 - if (needReloadData || self.forceReloadNextAppend) { - [self.messageTableView reloadData]; - self.forceReloadNextAppend = NO; - } 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 safeApplyInsertsFromStartIndex:currentRows newItemsCount:tempArray.count needReload:(needReloadData || self.forceReloadNextAppend)]; + self.forceReloadNextAppend = NO; //执行插入动画并滚动 // 延迟滚动执行,避免与礼物动画产生视觉冲突 diff --git a/YuMi/ru.lproj/Localizable.strings b/YuMi/ru.lproj/Localizable.strings index cfb65620..9a75ccdf 100644 --- a/YuMi/ru.lproj/Localizable.strings +++ b/YuMi/ru.lproj/Localizable.strings @@ -713,7 +713,7 @@ "AnchorLevelTimeView0" = "час(ов)"; "AnchorLevelTimeView1" = "минут(ы)"; -"AnchorLevelTimeView2" = "секунд(ы)"; +"AnchorLevelTimeView2" = "сек.(ы)"; "AnchorLevelProgressView0" = "Полученные монеты"; "AnchorLevelProgressView1" = "Завершено"; @@ -1237,12 +1237,12 @@ "XPRoomPKTimePickerView0" = "%d минут"; "XPRoomPKTimePickerView1" = "0 минут"; "XPRoomPKTimePickerView2" = "30 минут"; -"XPRoomPKTimePickerView3" = "%d минут %d секунд"; -"XPRoomPKTimePickerView4" = "%d секунд"; +"XPRoomPKTimePickerView3" = "%d минут %d сек."; +"XPRoomPKTimePickerView4" = "%d сек."; "XPRoomPKTimePickerView5" = "Отмена"; -"XPRoomPKTimePickerView7" = "0 секунд"; -"XPRoomPKTimePickerView8" = "30 секунд"; +"XPRoomPKTimePickerView7" = "0 сек."; +"XPRoomPKTimePickerView8" = "30 сек."; "XPRoomPKSelectUserView0" = "Номер 0"; @@ -1252,14 +1252,14 @@ "XPRoomPKUserView1" = "Ожидание подключения к микрофону"; "XPRoomPKTimeTableViewCell0" = "Время PK"; -"XPRoomPKTimeTableViewCell1" = "30 секунд"; +"XPRoomPKTimeTableViewCell1" = "30 сек."; "XPRoomPKTypeTableViewCell0" = "Режим PK"; "XPRoomPKTypeTableViewCell1" = "Командное PK"; "XPRoomPKVoteTableViewCell0" = "Тип голосования"; "XPRoomPKVoteTableViewCell1" = "Стоим. подар."; -"XPRoomPKVoteTableViewCell2" = "По количеству отправителей подарков"; +"XPRoomPKVoteTableViewCell2" = "Дар. чел."; "XPRoomPKRecordTableViewCell0" = "Ничья"; "XPRoomPKRecordTableViewCell1" = "Ничья"; @@ -1349,7 +1349,7 @@ "XPAcrossRoomPKRuleView0" = "Как начать PK между комнатами"; -"XPAcrossRoomPKRuleView1" = "1) Только владелец комнаты и супер-администратор лицензированной комнаты могут инициировать PK между комнатами, и за один раз можно выбрать только одну лицензированную комнату для инициирования;\n2) Только владелец комнаты и супер-администратор могут принимать или отклонять запросы на PK между комнатами. Если в течение 10 секунд не предпринято действий с всплывающим окном приглашения на PK, оно исчезнет, и это будет считаться автоматическим отказом;\n3) При инициировании PK необходимо выбрать время PK и цель PK. Настраиваемый диапазон времени составляет от 5 до 180, и можно вводить только целые числа;\n4) После инициирования PK его нельзя завершить раньше времени PK. В случае особых обстоятельств, требующих досрочного прекращения, обратитесь в службу поддержки, но результат этого PK не будет учитываться."; +"XPAcrossRoomPKRuleView1" = "1) Только владелец комнаты и супер-администратор лицензированной комнаты могут инициировать PK между комнатами, и за один раз можно выбрать только одну лицензированную комнату для инициирования;\n2) Только владелец комнаты и супер-администратор могут принимать или отклонять запросы на PK между комнатами. Если в течение 10 сек. не предпринято действий с всплывающим окном приглашения на PK, оно исчезнет, и это будет считаться автоматическим отказом;\n3) При инициировании PK необходимо выбрать время PK и цель PK. Настраиваемый диапазон времени составляет от 5 до 180, и можно вводить только целые числа;\n4) После инициирования PK его нельзя завершить раньше времени PK. В случае особых обстоятельств, требующих досрочного прекращения, обратитесь в службу поддержки, но результат этого PK не будет учитываться."; "XPAcrossRoomPKEmptyTableViewCell0" = "Нет результатов поиска"; @@ -1750,11 +1750,11 @@ "XPRoomMessageParser36" = "Результат PK в этом раунде: %@ победил! \nЗначение PK: %@ \n %@ Значение стража: %@"; "XPRoomMessageParser37" = "Побеждает команда с большим количеством полученных подарков"; "XPRoomMessageParser38" = "Побеждает команда с большим количеством отправителей"; -"XPRoomMessageParser39" = "Администратор запустил PK комнаты, этот PK длится %.0f секунд"; -"XPRoomMessageParser40" = "PK начался, этот PK длится %.0f секунд, быстро проголосуйте за своего любимого участника"; +"XPRoomMessageParser39" = "Администратор запустил PK комнаты, этот PK длится %.0f сек."; +"XPRoomMessageParser40" = "PK начался, этот PK длится %.0f сек., быстро проголосуйте за своего любимого участника"; "XPRoomMessageParser41" = "Побеждает команда с большим количеством полученных подарков"; "XPRoomMessageParser42" = "Побеждает команда с большим количеством отправителей"; -"XPRoomMessageParser43" = "Администратор перезапустил PK, этот PK длится %.0f секунд, %@"; +"XPRoomMessageParser43" = "Администратор перезапустил PK, этот PK длится %.0f сек., %@"; "XPRoomMessageParser44" = "Добавлена комната в закладки"; "XPRoomMessageParser45" = "Сообщение:"; "XPRoomMessageParser46" = "Чат публичного экрана закрыт администратором"; @@ -1823,7 +1823,7 @@ -"XPRoomMessageParser114" = "Управление инициировало PK комнаты, этот PK длится %.0f секунд, %@"; +"XPRoomMessageParser114" = "Управление инициировало PK комнаты, этот PK длится %.0f сек., %@"; "XPRoomMessageParser115" = "Впечатляюще! "; "XPRoomMessageParser116" = "Дух сокровища получен "; @@ -3433,7 +3433,7 @@ "XPFreeGiftsObtainView0"="Понятно"; "XPFreeGiftsObtainView1"="Сегодня смотрели прямой эфир в течение %@, даём вам подарок"; "XPFreeGiftsObtainView2"="Каждый день, просмотр прямого эфира в течение определенного времени приносит вам \"%@\", дневной лимит %@, подарок действителен в течение дня"; -"XPFreeGiftsObtainView3"="Секунд"; +"XPFreeGiftsObtainView3"="сек."; "XPFreeGiftsObtainView4"="Минут"; "XPFreeGiftsObtainView5"="Часов"; ///XPGiftFreeItemCell @@ -3558,7 +3558,7 @@ "App_Common_Or" = "Или"; "App_Common_Yuan" = "Юань"; "App_Common_Wan" = "Тысяча"; -"App_Common_Zero_Second" = "0 секунд"; +"App_Common_Zero_Second" = "0 сек."; "App_Common_zai" = "В"; "App_Common_bei" = "От"; "App_Common_gei" = "Для"; @@ -3930,7 +3930,7 @@ "20.20.51_text_27" = "1 Каждый раз, когда вы загружаете динамический аватар, система списывает %@ монет с вашего аккаунта. Пожалуйста, убедитесь, что баланс вашего аккаунта достаточен.\n2 Процесс проверки: После завершения загрузки система автоматически переходит в процесс проверки. В это время не загружайте новый аватар, чтобы не повлиять на прогресс проверки.\n3 Результаты проверки:\nПроверка пройдена: Ваш новый динамический аватар вступит в силу немедленно и будет отображаться другим пользователям.\nПроверка не пройдена: После завершения проверки система автоматически вернет вам потраченные %@ монеты. В то же время вы получите системное уведомление.\n4 Нереверсивная операция: Пожалуйста, обратите внимание, что после проверки и вступления в силу динамический аватар вы не сможете восстановить предыдущий аватар.\n"; "20.20.51_text_28" = "Время просмотра: %@"; "20.20.51_text_29" = "Количество алмазов должно быть кратно 1000"; -"20.20.51_text_30" = "Продолжительность видео не должна превышать 10 секунд."; +"20.20.51_text_30" = "Продолжительность видео не должна превышать 10 сек.."; "20.20.56_text_1" = "Текущее количество матчей недостаточно, матч не удался. Сыграть снова?"; "20.20.56_text_2" = "Матч не удался, потраченные вами монеты возвращены в Кошелёк";