// // KBChatTableView.m // keyBoard // // Created by Kiro on 2026/1/23. // #import "KBChatTableView.h" #import "KBAiChatMessage.h" #import "KBChatUserMessageCell.h" #import "KBChatAssistantMessageCell.h" #import "KBChatTimeCell.h" #import "AiVM.h" #import #import #import static NSString * const kUserCellIdentifier = @"KBChatUserMessageCell"; static NSString * const kAssistantCellIdentifier = @"KBChatAssistantMessageCell"; static NSString * const kTimeCellIdentifier = @"KBChatTimeCell"; /// 时间戳显示间隔(秒) static const NSTimeInterval kTimestampInterval = 5 * 60; // 5 分钟 @interface KBChatTableView () @property (nonatomic, strong) BaseTableView *tableView; @property (nonatomic, strong) NSMutableArray *messages; @property (nonatomic, strong) AVAudioPlayer *audioPlayer; @property (nonatomic, strong) NSIndexPath *playingCellIndexPath; @property (nonatomic, strong) AiVM *aiVM; @property (nonatomic, assign) BOOL hasMoreData; @property (nonatomic, assign) CGFloat contentBottomInset; @end @implementation KBChatTableView - (instancetype)initWithFrame:(CGRect)frame { self = [super initWithFrame:frame]; if (self) { [self setup]; } return self; } - (instancetype)initWithCoder:(NSCoder *)coder { self = [super initWithCoder:coder]; if (self) { [self setup]; } return self; } - (void)setup { self.messages = [[NSMutableArray alloc] init]; self.aiVM = [[AiVM alloc] init]; self.hasMoreData = YES; // 创建 TableView self.tableView = [[BaseTableView alloc] initWithFrame:self.bounds style:UITableViewStylePlain]; self.tableView.useEmptyDataSet = false; self.tableView.dataSource = self; self.tableView.delegate = self; self.tableView.separatorStyle = UITableViewCellSeparatorStyleNone; self.tableView.backgroundColor = [UIColor clearColor]; // 关键修复:使用合理的估算高度(避免抖动,但不能为0) self.tableView.estimatedRowHeight = 80; self.tableView.estimatedSectionHeaderHeight = 0; self.tableView.estimatedSectionFooterHeight = 0; self.tableView.rowHeight = UITableViewAutomaticDimension; self.tableView.showsVerticalScrollIndicator = YES; // 关键修复:禁用内容自动调整,防止与外层 CollectionView 冲突 if (@available(iOS 11.0, *)) { self.tableView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever; } [self addSubview:self.tableView]; // 注册 Cell [self.tableView registerClass:[KBChatUserMessageCell class] forCellReuseIdentifier:kUserCellIdentifier]; [self.tableView registerClass:[KBChatAssistantMessageCell class] forCellReuseIdentifier:kAssistantCellIdentifier]; [self.tableView registerClass:[KBChatTimeCell class] forCellReuseIdentifier:kTimeCellIdentifier]; // 布局 [self.tableView mas_makeConstraints:^(MASConstraintMaker *make) { make.edges.equalTo(self); }]; // 初始化 contentInset self.contentBottomInset = 0; [self updateContentBottomInset:self.contentBottomInset]; // 暂时禁用 mj_footer,排查问题 // TODO: 如果需要加载更多功能,重新启用 /* __weak typeof(self) weakSelf = self; self.tableView.mj_footer = [MJRefreshAutoNormalFooter footerWithRefreshingBlock:^{ __strong typeof(weakSelf) strongSelf = weakSelf; if (!strongSelf) { return; } if (!strongSelf.hasMoreData) { [strongSelf.tableView.mj_footer endRefreshingWithNoMoreData]; return; } if ([strongSelf.delegate respondsToSelector:@selector(chatTableViewDidTriggerLoadMore:)]) { [strongSelf.delegate chatTableViewDidTriggerLoadMore:strongSelf]; } else { [strongSelf.tableView.mj_footer endRefreshing]; } }]; // 隐藏"已经全部加载完毕"的提示文字 // MJRefreshAutoNormalFooter *footer = (MJRefreshAutoNormalFooter *)self.tableView.mj_footer; // footer.stateLabel.hidden = YES; // 隐藏状态文字 // footer.refreshingBlock = footer.refreshingBlock; // 保持刷新逻辑 // self.tableView.mj_footer.hidden = YES; */ } #pragma mark - Public Methods - (void)addUserMessage:(NSString *)text { KBAiChatMessage *message = [KBAiChatMessage userMessageWithText:text]; [self addMessage:message autoScroll:YES]; } - (void)addLoadingUserMessage { KBAiChatMessage *message = [KBAiChatMessage loadingUserMessage]; [self addMessage:message autoScroll:YES]; } - (void)updateLastUserMessage:(NSString *)text { for (NSInteger i = self.messages.count - 1; i >= 0; i--) { KBAiChatMessage *message = self.messages[i]; if (message.type == KBAiChatMessageTypeUser && message.isLoading) { message.text = text; message.isLoading = NO; message.isComplete = YES; // 刷新该行 NSIndexPath *indexPath = [NSIndexPath indexPathForRow:i inSection:0]; [self.tableView reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone]; return; } } } - (void)addAssistantMessage:(NSString *)text audioDuration:(NSTimeInterval)duration audioData:(NSData *)audioData { KBAiChatMessage *message = [KBAiChatMessage assistantMessageWithText:text audioDuration:duration audioData:audioData]; [self addMessage:message autoScroll:YES]; } - (void)addAssistantMessage:(NSString *)text audioId:(NSString *)audioId { KBAiChatMessage *message = [KBAiChatMessage assistantMessageWithText:text audioId:audioId]; message.needsTypewriterEffect = YES; // 新消息需要打字机效果 [self addMessage:message autoScroll:YES]; } - (void)updateLastAssistantMessage:(NSString *)text { // 查找最后一条未完成的 AI 消息 for (NSInteger i = self.messages.count - 1; i >= 0; i--) { KBAiChatMessage *message = self.messages[i]; if (message.type == KBAiChatMessageTypeAssistant && !message.isComplete) { NSLog(@"[KBChatTableView] 更新最后一条 AI 消息 - 索引: %ld, 文本长度: %lu, needsTypewriter: %d", (long)i, (unsigned long)text.length, message.needsTypewriterEffect); message.text = text; // 直接更新 Cell 的文本,不刷新整个 Cell(避免打断打字机效果) NSIndexPath *indexPath = [NSIndexPath indexPathForRow:i inSection:0]; KBChatAssistantMessageCell *cell = [self.tableView cellForRowAtIndexPath:indexPath]; if ([cell isKindOfClass:[KBChatAssistantMessageCell class]]) { NSLog(@"[KBChatTableView] 找到 Cell,直接配置消息"); // 直接调用 configureWithMessage,让 Cell 自己决定是否使用打字机效果 [cell configureWithMessage:message]; } else { NSLog(@"[KBChatTableView] 未找到 Cell 或类型不匹配"); } return; } } // 如果没找到,添加新消息 NSLog(@"[KBChatTableView] 未找到未完成的 AI 消息,添加新消息"); [self addAssistantMessage:text audioDuration:0 audioData:nil]; } - (void)markLastAssistantMessageComplete { for (NSInteger i = self.messages.count - 1; i >= 0; i--) { KBAiChatMessage *message = self.messages[i]; if (message.type == KBAiChatMessageTypeAssistant) { NSLog(@"[KBChatTableView] 标记消息完成 - 索引: %ld, 文本: %@", (long)i, message.text); message.isComplete = YES; message.needsTypewriterEffect = NO; // 完成后不再需要打字机效果 // 刷新 Cell 以显示完整文本 NSIndexPath *indexPath = [NSIndexPath indexPathForRow:i inSection:0]; [self.tableView reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone]; return; } } } - (void)markLastUserMessageLoadingComplete { for (NSInteger i = self.messages.count - 1; i >= 0; i--) { KBAiChatMessage *message = self.messages[i]; if (message.type == KBAiChatMessageTypeUser) { message.isLoading = NO; NSIndexPath *indexPath = [NSIndexPath indexPathForRow:i inSection:0]; [self.tableView reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone]; [self.tableView layoutIfNeeded]; [self scrollToBottomAnimated:NO]; return; } } } - (void)clearMessages { [self.messages removeAllObjects]; [self.tableView reloadData]; [self updateFooterVisibility]; } - (void)removeLoadingAssistantMessage { for (NSInteger i = self.messages.count - 1; i >= 0; i--) { KBAiChatMessage *message = self.messages[i]; if (message.type == KBAiChatMessageTypeAssistant && message.isLoading) { [self.messages removeObjectAtIndex:i]; NSIndexPath *indexPath = [NSIndexPath indexPathForRow:i inSection:0]; [self.tableView deleteRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone]; NSLog(@"[KBChatTableView] 移除 loading AI 消息,索引: %ld", (long)i); break; } } } - (void)scrollToBottom { [self scrollToBottomAnimated:YES]; } - (void)scrollToBottomAnimated:(BOOL)animated { if (self.messages.count == 0) return; // 关键修复:使用 layoutIfNeeded 确保布局完成后再滚动 [self.tableView layoutIfNeeded]; // 计算需要滚动到的位置 CGFloat contentHeight = self.tableView.contentSize.height; CGFloat tableViewHeight = self.tableView.bounds.size.height; CGFloat bottomInset = self.tableView.contentInset.bottom; // 计算滚动到底部的 offset CGFloat offsetY = contentHeight - tableViewHeight + bottomInset; offsetY = MAX(0, offsetY); NSLog(@"[KBChatTableView] scrollToBottom - contentHeight: %.2f, tableViewHeight: %.2f, bottomInset: %.2f, offsetY: %.2f", contentHeight, tableViewHeight, bottomInset, offsetY); [self.tableView setContentOffset:CGPointMake(0, offsetY) animated:animated]; } #pragma mark - Public Helpers - (void)endLoadMoreWithHasMoreData:(BOOL)hasMoreData { self.hasMoreData = hasMoreData; // 暂时禁用 mj_footer // if (hasMoreData) { // [self.tableView.mj_footer endRefreshing]; // } else { // [self.tableView.mj_footer endRefreshingWithNoMoreData]; // } [self updateFooterVisibility]; } - (void)resetNoMoreData { self.hasMoreData = YES; // 暂时禁用 mj_footer // [self.tableView.mj_footer resetNoMoreData]; [self updateFooterVisibility]; } - (void)updateContentBottomInset:(CGFloat)bottomInset { self.contentBottomInset = bottomInset; // 直接设置 contentInset UIEdgeInsets insets = UIEdgeInsetsZero; insets.bottom = bottomInset; self.tableView.contentInset = insets; self.tableView.scrollIndicatorInsets = insets; NSLog(@"[KBChatTableView] updateContentBottomInset: %.2f", bottomInset); } - (void)addMessage:(KBAiChatMessage *)message autoScroll:(BOOL)autoScroll { if (!message) { return; } if (message.type == KBAiChatMessageTypeAssistant && message.needsTypewriterEffect && !message.isComplete) { [self stopPreviousIncompleteAssistantMessageIfNeeded]; } NSInteger oldCount = self.messages.count; [self insertMessageWithTimestamp:message]; NSInteger newCount = self.messages.count; NSInteger insertedCount = newCount - oldCount; if (insertedCount > 0) { NSMutableArray *indexPaths = [NSMutableArray array]; for (NSInteger i = oldCount; i < newCount; i++) { [indexPaths addObject:[NSIndexPath indexPathForRow:i inSection:0]]; } // 关键修复:批量插入前先布局,避免高度计算不准确 [self.tableView layoutIfNeeded]; [self.tableView insertRowsAtIndexPaths:indexPaths withRowAnimation:UITableViewRowAnimationNone]; } [self updateFooterVisibility]; if (autoScroll) { // 关键修复:插入完成后立即滚动,使用 dispatch_async 确保插入动画完成 dispatch_async(dispatch_get_main_queue(), ^{ [self.tableView layoutIfNeeded]; // 再次确保布局完成 [self scrollToBottomAnimated:YES]; // 二次滚动以确保底部完全可见(解决自动行高导致的布局偏差) dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ [self.tableView layoutIfNeeded]; [self scrollToBottomAnimated:NO]; }); }); } // 自动预加载音频(不自动播放) if (message.type == KBAiChatMessageTypeAssistant && message.audioId.length > 0) { [self preloadAudioForMessage:message]; } } - (void)reloadWithMessages:(NSArray *)messages keepOffset:(BOOL)keepOffset scrollToBottom:(BOOL)scrollToBottom { CGFloat oldContentHeight = self.tableView.contentSize.height; CGFloat oldOffsetY = self.tableView.contentOffset.y; [self.messages removeAllObjects]; if (messages.count > 0) { for (KBAiChatMessage *message in messages) { [self insertMessageWithTimestamp:message]; } } NSLog(@"[KBChatTableView] ========== reloadWithMessages 开始 =========="); NSLog(@"[KBChatTableView] 消息数量: %ld", (long)self.messages.count); NSLog(@"[KBChatTableView] tableView.frame: %@", NSStringFromCGRect(self.tableView.frame)); NSLog(@"[KBChatTableView] tableView.bounds: %@", NSStringFromCGRect(self.tableView.bounds)); NSLog(@"[KBChatTableView] 刷新前 contentSize: %@", NSStringFromCGSize(self.tableView.contentSize)); NSLog(@"[KBChatTableView] 刷新前 contentInset: %@", NSStringFromUIEdgeInsets(self.tableView.contentInset)); [self.tableView reloadData]; [self.tableView layoutIfNeeded]; [self updateFooterVisibility]; NSLog(@"[KBChatTableView] 刷新后 contentSize: %@", NSStringFromCGSize(self.tableView.contentSize)); NSLog(@"[KBChatTableView] 刷新后 contentInset: %@", NSStringFromUIEdgeInsets(self.tableView.contentInset)); NSLog(@"[KBChatTableView] 刷新后 contentOffset: %@", NSStringFromCGPoint(self.tableView.contentOffset)); // 打印每个 Cell 的高度 for (NSInteger i = 0; i < self.messages.count; i++) { CGRect cellRect = [self.tableView rectForRowAtIndexPath:[NSIndexPath indexPathForRow:i inSection:0]]; KBAiChatMessage *msg = self.messages[i]; NSLog(@"[KBChatTableView] Cell[%ld] type=%ld, height=%.2f, text=%@", (long)i, (long)msg.type, cellRect.size.height, msg.text.length > 20 ? [msg.text substringToIndex:20] : msg.text); } NSLog(@"[KBChatTableView] ========== reloadWithMessages 结束 =========="); if (keepOffset) { CGFloat newContentHeight = self.tableView.contentSize.height; CGFloat delta = newContentHeight - oldContentHeight; CGFloat offsetY = oldOffsetY + delta; // 关键修复:使用非动画方式设置 offset,避免抖动 [self.tableView setContentOffset:CGPointMake(0, offsetY) animated:NO]; return; } if (scrollToBottom) { NSLog(@"[KBChatTableView] 准备滚动到底部..."); // 关键修复:使用 dispatch_after 延迟执行,确保 reloadData 和布局完全完成 __weak typeof(self) weakSelf = self; dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ __strong typeof(weakSelf) strongSelf = weakSelf; if (!strongSelf) { NSLog(@"[KBChatTableView] ⚠️ strongSelf 为空,跳过滚动"); return; } NSLog(@"[KBChatTableView] dispatch_after 执行 scrollToBottom"); NSLog(@"[KBChatTableView] 滚动前 contentSize: %@", NSStringFromCGSize(strongSelf.tableView.contentSize)); NSLog(@"[KBChatTableView] 滚动前 tableView.frame: %@", NSStringFromCGRect(strongSelf.tableView.frame)); [strongSelf scrollToBottomAnimated:NO]; NSLog(@"[KBChatTableView] scrollToBottom 后 contentOffset: %@", NSStringFromCGPoint(strongSelf.tableView.contentOffset)); }); } } #pragma mark - Private Methods /// 插入消息并自动添加时间戳 - (void)insertMessageWithTimestamp:(KBAiChatMessage *)message { // 判断是否需要插入时间戳 if ([self shouldInsertTimestampForMessage:message]) { KBAiChatMessage *timeMessage = [KBAiChatMessage timeMessageWithTimestamp:message.timestamp]; [self.messages addObject:timeMessage]; } [self.messages addObject:message]; } /// 判断是否需要插入时间戳 - (BOOL)shouldInsertTimestampForMessage:(KBAiChatMessage *)message { // 第一条消息总是显示时间 if (self.messages.count == 0) { return YES; } // 查找最后一条非时间戳消息 KBAiChatMessage *lastMessage = nil; for (NSInteger i = self.messages.count - 1; i >= 0; i--) { KBAiChatMessage *msg = self.messages[i]; if (msg.type != KBAiChatMessageTypeTime) { lastMessage = msg; break; } } if (!lastMessage) { return YES; } // 计算时间间隔 NSTimeInterval interval = [message.timestamp timeIntervalSinceDate:lastMessage.timestamp]; // 超过 5 分钟或跨天则显示时间 if (interval >= kTimestampInterval) { return YES; } NSCalendar *calendar = [NSCalendar currentCalendar]; NSDateComponents *lastComponents = [calendar components:NSCalendarUnitDay | NSCalendarUnitMonth | NSCalendarUnitYear fromDate:lastMessage.timestamp]; NSDateComponents *currentComponents = [calendar components:NSCalendarUnitDay | NSCalendarUnitMonth | NSCalendarUnitYear fromDate:message.timestamp]; return ![lastComponents isEqual:currentComponents]; } /// 刷新并滚动到底部 - (void)reloadAndScroll { // 使用 insert 而不是 reload,避免刷新已有的 Cell NSInteger lastIndex = self.messages.count - 1; if (lastIndex >= 0) { NSIndexPath *indexPath = [NSIndexPath indexPathForRow:lastIndex inSection:0]; [self.tableView insertRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone]; } dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ [self scrollToBottom]; }); } - (void)stopPreviousIncompleteAssistantMessageIfNeeded { for (NSInteger i = self.messages.count - 1; i >= 0; i--) { KBAiChatMessage *msg = self.messages[i]; if (msg.type == KBAiChatMessageTypeAssistant && !msg.isComplete) { msg.isComplete = YES; msg.needsTypewriterEffect = NO; NSIndexPath *oldIndexPath = [NSIndexPath indexPathForRow:i inSection:0]; KBChatAssistantMessageCell *oldCell = [self.tableView cellForRowAtIndexPath:oldIndexPath]; if ([oldCell isKindOfClass:[KBChatAssistantMessageCell class]]) { [oldCell stopTypewriterEffect]; oldCell.messageLabel.text = msg.text; } break; } } } - (void)updateFooterVisibility { // 暂时禁用 mj_footer // BOOL canLoadMore = (self.delegate && // [self.delegate respondsToSelector:@selector(chatTableViewDidTriggerLoadMore:)]); // self.tableView.mj_footer.hidden = !canLoadMore || self.messages.count == 0; } #pragma mark - UITableViewDataSource - (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section { return self.messages.count; } - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { KBAiChatMessage *message = self.messages[indexPath.row]; NSLog(@"[KBChatTableView] cellForRow: %ld, 消息类型: %ld, needsTypewriter: %d, isComplete: %d", (long)indexPath.row, (long)message.type, message.needsTypewriterEffect, message.isComplete); switch (message.type) { case KBAiChatMessageTypeUser: { KBChatUserMessageCell *cell = [tableView dequeueReusableCellWithIdentifier:kUserCellIdentifier forIndexPath:indexPath]; [cell configureWithMessage:message]; return cell; } case KBAiChatMessageTypeAssistant: { KBChatAssistantMessageCell *cell = [tableView dequeueReusableCellWithIdentifier:kAssistantCellIdentifier forIndexPath:indexPath]; cell.delegate = self; [cell configureWithMessage:message]; // 更新播放状态 BOOL isPlaying = [indexPath isEqual:self.playingCellIndexPath]; [cell updateVoicePlayingState:isPlaying]; return cell; } case KBAiChatMessageTypeTime: { KBChatTimeCell *cell = [tableView dequeueReusableCellWithIdentifier:kTimeCellIdentifier forIndexPath:indexPath]; [cell configureWithMessage:message]; return cell; } } } - (void)setDelegate:(id)delegate { _delegate = delegate; [self updateFooterVisibility]; } #pragma mark - UIScrollViewDelegate - (void)scrollViewDidScroll:(UIScrollView *)scrollView { if ([self.delegate respondsToSelector:@selector(chatTableViewDidScroll:scrollView:)]) { [self.delegate chatTableViewDidScroll:self scrollView:scrollView]; } } /// 关键修复:优化嵌套滚动体验,减少边界弹簧效果导致的抖动 - (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset { CGFloat offsetY = scrollView.contentOffset.y; CGFloat contentHeight = scrollView.contentSize.height; CGFloat scrollViewHeight = scrollView.bounds.size.height; // 如果内容不够长,禁用弹簧效果 if (contentHeight <= scrollViewHeight) { scrollView.bounces = NO; } else { scrollView.bounces = YES; // 在快速滑动到底部时,避免过度弹簧导致抖动 if (velocity.y < 0) { // 向上滑动(到底部) CGFloat maxOffset = contentHeight - scrollViewHeight + scrollView.contentInset.bottom; if (targetContentOffset->y > maxOffset) { targetContentOffset->y = maxOffset; } } } } #pragma mark - KBChatAssistantMessageCellDelegate - (void)assistantMessageCell:(KBChatAssistantMessageCell *)cell didTapVoiceButtonForMessage:(KBAiChatMessage *)message { NSIndexPath *indexPath = [self.tableView indexPathForCell:cell]; if (!indexPath) return; // 如果正在播放同一条消息,则暂停 if ([indexPath isEqual:self.playingCellIndexPath]) { [self stopPlayingAudio]; return; } // 停止之前的播放 [self stopPlayingAudio]; // 如果有 audioData,直接播放 if (message.audioData && message.audioData.length > 0) { [self playAudioForMessage:message atIndexPath:indexPath]; return; } // 如果有 audioId,异步加载音频 if (message.audioId.length > 0) { [self loadAndPlayAudioForMessage:message atIndexPath:indexPath]; return; } NSLog(@"[KBChatTableView] 没有音频数据或 audioId"); } #pragma mark - Audio Playback - (void)loadAndPlayAudioForMessage:(KBAiChatMessage *)message atIndexPath:(NSIndexPath *)indexPath { // 显示加载动画 KBChatAssistantMessageCell *cell = [self.tableView cellForRowAtIndexPath:indexPath]; if ([cell isKindOfClass:[KBChatAssistantMessageCell class]]) { [cell showLoadingAnimation]; } // 开始轮询请求(最多10次,每次间隔1秒,共10秒) [self pollAudioForMessage:message atIndexPath:indexPath retryCount:0 maxRetries:10]; } - (void)pollAudioForMessage:(KBAiChatMessage *)message atIndexPath:(NSIndexPath *)indexPath retryCount:(NSInteger)retryCount maxRetries:(NSInteger)maxRetries { __weak typeof(self) weakSelf = self; [self.aiVM requestAudioWithAudioId:message.audioId completion:^(NSString *_Nullable audioURL, NSError *_Nullable error) { __strong typeof(weakSelf) strongSelf = weakSelf; if (!strongSelf) return; dispatch_async(dispatch_get_main_queue(), ^{ // 如果成功获取到 audioURL if (!error && audioURL.length > 0) { NSLog(@"[KBChatTableView] 音频 URL 获取成功(第 %ld 次)", (long)(retryCount + 1)); // 下载并播放音频 [strongSelf downloadAndPlayAudioFromURL:audioURL forMessage:message atIndexPath:indexPath]; return; } // 如果还没达到最大重试次数,继续轮询 if (retryCount < maxRetries - 1) { NSLog(@"[KBChatTableView] 音频未就绪,1秒后重试 (%ld/%ld)", (long)(retryCount + 1), (long)maxRetries); // 1秒后重试 dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ [strongSelf pollAudioForMessage:message atIndexPath:indexPath retryCount:retryCount + 1 maxRetries:maxRetries]; }); } else { // 达到最大重试次数,隐藏加载动画 KBChatAssistantMessageCell *cell = [strongSelf.tableView cellForRowAtIndexPath:indexPath]; if ([cell isKindOfClass:[KBChatAssistantMessageCell class]]) { [cell hideLoadingAnimation]; } NSLog(@"[KBChatTableView] 音频加载失败,已重试 %ld 次", (long)maxRetries); } }); }]; } - (void)downloadAndPlayAudioFromURL:(NSString *)urlString forMessage:(KBAiChatMessage *)message atIndexPath:(NSIndexPath *)indexPath { NSURL *url = [NSURL URLWithString:urlString]; if (!url) { NSLog(@"[KBChatTableView] 无效的音频 URL: %@", urlString); // 隐藏加载动画 KBChatAssistantMessageCell *cell = [self.tableView cellForRowAtIndexPath:indexPath]; if ([cell isKindOfClass:[KBChatAssistantMessageCell class]]) { [cell hideLoadingAnimation]; } return; } NSURLSessionConfiguration *config = [NSURLSessionConfiguration defaultSessionConfiguration]; NSURLSession *session = [NSURLSession sessionWithConfiguration:config]; NSURLSessionDataTask *task = [session dataTaskWithURL:url completionHandler:^(NSData *_Nullable data, NSURLResponse *_Nullable response, NSError *_Nullable error) { if (error || !data || data.length == 0) { NSLog(@"[KBChatTableView] 下载音频失败: %@", error.localizedDescription ?: @""); dispatch_async(dispatch_get_main_queue(), ^{ // 隐藏加载动画 KBChatAssistantMessageCell *cell = [self.tableView cellForRowAtIndexPath:indexPath]; if ([cell isKindOfClass:[KBChatAssistantMessageCell class]]) { [cell hideLoadingAnimation]; } }); return; } dispatch_async(dispatch_get_main_queue(), ^{ // 隐藏加载动画 KBChatAssistantMessageCell *cell = [self.tableView cellForRowAtIndexPath:indexPath]; if ([cell isKindOfClass:[KBChatAssistantMessageCell class]]) { [cell hideLoadingAnimation]; } // 缓存音频数据到消息对象 message.audioData = data; // 计算音频时长 NSError *playerError = nil; AVAudioPlayer *player = [[AVAudioPlayer alloc] initWithData:data error:&playerError]; if (!playerError && player) { message.audioDuration = player.duration; } // 不刷新 Cell,避免触发打字机效果 // 直接播放音频 [self playAudioForMessage:message atIndexPath:indexPath]; }); }]; [task resume]; } - (void)playAudioForMessage:(KBAiChatMessage *)message atIndexPath:(NSIndexPath *)indexPath { if (!message.audioData || message.audioData.length == 0) { NSLog(@"[KBChatTableView] 没有音频数据"); return; } // 配置音频会话为播放模式 NSError *sessionError = nil; AVAudioSession *audioSession = [AVAudioSession sharedInstance]; [audioSession setCategory:AVAudioSessionCategoryPlayback error:&sessionError]; [audioSession setActive:YES error:&sessionError]; if (sessionError) { NSLog(@"[KBChatTableView] 音频会话配置失败: %@", sessionError.localizedDescription); } NSError *error = nil; self.audioPlayer = [[AVAudioPlayer alloc] initWithData:message.audioData error:&error]; if (error || !self.audioPlayer) { NSLog(@"[KBChatTableView] 音频播放器初始化失败: %@", error.localizedDescription); return; } self.audioPlayer.delegate = self; self.audioPlayer.volume = 1.0; // 设置音量为最大 [self.audioPlayer prepareToPlay]; [self.audioPlayer play]; self.playingCellIndexPath = indexPath; // 更新 Cell 状态,禁用动画避免 TableView 跳动 KBChatAssistantMessageCell *cell = [self.tableView cellForRowAtIndexPath:indexPath]; if ([cell isKindOfClass:[KBChatAssistantMessageCell class]]) { [UIView performWithoutAnimation:^{ [cell updateVoicePlayingState:YES]; }]; } } - (void)stopPlayingAudio { if (self.audioPlayer && self.audioPlayer.isPlaying) { [self.audioPlayer stop]; } if (self.playingCellIndexPath) { KBChatAssistantMessageCell *cell = [self.tableView cellForRowAtIndexPath:self.playingCellIndexPath]; if ([cell isKindOfClass:[KBChatAssistantMessageCell class]]) { // 禁用动画,避免 TableView 跳动 [UIView performWithoutAnimation:^{ [cell updateVoicePlayingState:NO]; }]; } self.playingCellIndexPath = nil; } } #pragma mark - Audio Preload (自动预加载,不播放) /// 预加载音频(不自动播放) - (void)preloadAudioForMessage:(KBAiChatMessage *)message { if (!message || message.audioId.length == 0) { return; } // 如果已经有音频数据,不需要预加载 if (message.audioData && message.audioData.length > 0) { NSLog(@"[KBChatTableView] 音频已缓存,跳过预加载"); return; } NSLog(@"[KBChatTableView] 开始预加载音频,audioId: %@", message.audioId); // 记录开始时间 NSDate *startTime = [NSDate date]; // 开始轮询请求(最多10次,每次间隔1秒,共10秒) [self pollPreloadAudioForMessage:message retryCount:0 maxRetries:10 startTime:startTime]; } /// 轮询预加载音频 - (void)pollPreloadAudioForMessage:(KBAiChatMessage *)message retryCount:(NSInteger)retryCount maxRetries:(NSInteger)maxRetries startTime:(NSDate *)startTime { __weak typeof(self) weakSelf = self; [self.aiVM requestAudioWithAudioId:message.audioId completion:^(NSString *_Nullable audioURL, NSError *_Nullable error) { __strong typeof(weakSelf) strongSelf = weakSelf; if (!strongSelf) return; dispatch_async(dispatch_get_main_queue(), ^{ // 如果成功获取到 audioURL if (!error && audioURL.length > 0) { // 计算获取 audioURL 的耗时 NSTimeInterval elapsed = [[NSDate date] timeIntervalSinceDate:startTime]; NSLog(@"[KBChatTableView] ✅ 预加载音频 URL 获取成功(第 %ld 次),耗时: %.2f 秒", (long)(retryCount + 1), elapsed); // 下载音频(不播放) [strongSelf downloadAudioFromURL:audioURL forMessage:message startTime:startTime]; return; } // 如果还没达到最大重试次数,继续轮询 if (retryCount < maxRetries - 1) { NSLog(@"[KBChatTableView] 预加载音频未就绪,1秒后重试 (%ld/%ld)", (long)(retryCount + 1), (long)maxRetries); // 1秒后重试(给后端更多时间生成音频) dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ [strongSelf pollPreloadAudioForMessage:message retryCount:retryCount + 1 maxRetries:maxRetries startTime:startTime]; }); } else { NSTimeInterval elapsed = [[NSDate date] timeIntervalSinceDate:startTime]; NSLog(@"[KBChatTableView] ❌ 预加载音频失败,已重试 %ld 次,总耗时: %.2f 秒", (long)maxRetries, elapsed); } }); }]; } /// 下载音频(不播放) - (void)downloadAudioFromURL:(NSString *)urlString forMessage:(KBAiChatMessage *)message startTime:(NSDate *)startTime { NSURL *url = [NSURL URLWithString:urlString]; if (!url) { NSLog(@"[KBChatTableView] 预加载:无效的音频 URL: %@", urlString); return; } NSURLSessionConfiguration *config = [NSURLSessionConfiguration defaultSessionConfiguration]; NSURLSession *session = [NSURLSession sessionWithConfiguration:config]; NSURLSessionDataTask *task = [session dataTaskWithURL:url completionHandler:^(NSData *_Nullable data, NSURLResponse *_Nullable response, NSError *_Nullable error) { if (error || !data || data.length == 0) { NSLog(@"[KBChatTableView] 预加载:下载音频失败: %@", error.localizedDescription ?: @""); return; } dispatch_async(dispatch_get_main_queue(), ^{ // 缓存音频数据到消息对象 message.audioData = data; // 计算音频时长 NSError *playerError = nil; AVAudioPlayer *player = [[AVAudioPlayer alloc] initWithData:data error:&playerError]; if (!playerError && player) { message.audioDuration = player.duration; } // 计算总耗时(从开始请求到下载完成) NSTimeInterval totalElapsed = [[NSDate date] timeIntervalSinceDate:startTime]; NSLog(@"[KBChatTableView] ✅ 预加载音频完成,音频时长: %.2f秒,总耗时: %.2f 秒", message.audioDuration, totalElapsed); }); }]; [task resume]; } #pragma mark - AVAudioPlayerDelegate - (void)audioPlayerDidFinishPlaying:(AVAudioPlayer *)player successfully:(BOOL)flag { [self stopPlayingAudio]; } @end