在 MsRoomMessageMainView 和 XPRoomMessageContainerView 中添加了主线程调度以确保 UI 更新的安全性,同时实现了安全插入逻辑以处理数据与 UI 不一致的情况,优化了本地化字符串以提升用户体验。
This commit is contained in:
@@ -112,15 +112,21 @@
|
||||
|
||||
#pragma mark - RoomGuestDelegate
|
||||
- (void)handleNIMCustomMessage:(NIMMessage *)message {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
[self.messageListView handleNIMCustomMessage:message];
|
||||
});
|
||||
}
|
||||
|
||||
- (void)handleNIMNotificationMessage:(NIMMessage *)message {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
[self.messageListView handleNIMNotificationMessage:message];
|
||||
});
|
||||
}
|
||||
|
||||
- (void)handleNIMTextMessage:(NIMMessage *)message {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
[self.messageListView handleNIMTextMessage:message];
|
||||
});
|
||||
}
|
||||
|
||||
- (void)handleNIMImageMessage:(NIMMessage *)message {
|
||||
@@ -129,16 +135,22 @@
|
||||
|
||||
- (void)onRoomMiniEntered {
|
||||
self.hidden = NO;
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
[self.messageListView onRoomMiniEntered];
|
||||
});
|
||||
}
|
||||
|
||||
- (void)onRoomEntered {
|
||||
self.hidden = NO;
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
[self.messageListView onRoomEntered];
|
||||
});
|
||||
}
|
||||
|
||||
- (void)onRoomUpdate {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
[self.messageListView onRoomUpdate];
|
||||
});
|
||||
}
|
||||
#pragma mark - XPRoomMessageContainerViewDelegate
|
||||
- (void)xPRoomMessageContainerViewlDidTapEmpty:(XPRoomMessageContainerView *)view{
|
||||
@@ -165,7 +177,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
[self.messageListView handleBroadcastMessage:broadcastMessage];
|
||||
});
|
||||
}
|
||||
|
||||
#pragma mark - 懒加载
|
||||
|
@@ -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<dispatch_block_t> *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<NSIndexPath *> *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 safeApplyInsertsFromStartIndex:currentRows newItemsCount:tempNewDatas.count needReload:(needReloadData || self.forceReloadNextAppend)];
|
||||
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];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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<dispatch_block_t> *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 safeApplyInsertsFromStartIndex:currentRows newItemsCount:tempArray.count needReload:(needReloadData || self.forceReloadNextAppend)];
|
||||
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];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//执行插入动画并滚动
|
||||
// 延迟滚动执行,避免与礼物动画产生视觉冲突
|
||||
|
@@ -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" = "Матч не удался, потраченные вами монеты возвращены в Кошелёк";
|
||||
|
Reference in New Issue
Block a user