2
This commit is contained in:
@@ -33,6 +33,10 @@ typedef void (^KBStreamFetcherFinishHandler)(NSError *_Nullable error);
|
||||
@property (nonatomic, assign) BOOL treatSlashTAsTab; // 默认 YES;将“/t”替换为“\t”
|
||||
@property (nonatomic, assign) BOOL trimLeadingTabOnce; // 默认 YES;首次正文起始的“\t”删一个(忽略前导空白)
|
||||
@property (nonatomic, assign) NSTimeInterval requestTimeout; // 默认 30s
|
||||
/// UI 刷新节奏:当一次回调内解析出多段(如多条 SSE 事件)时,按该间隔逐条回调(默认 0.10s)。
|
||||
@property (nonatomic, assign) NSTimeInterval flushInterval;
|
||||
/// 非 SSE 且一次性拿到大段文本时,是否按空格切词逐条回调(模拟“逐词流式”),默认 YES。
|
||||
@property (nonatomic, assign) BOOL splitLargeDeltasOnWhitespace;
|
||||
|
||||
// 回调(统一在主线程触发)
|
||||
@property (nonatomic, copy, nullable) KBStreamFetcherChunkHandler onChunk;
|
||||
@@ -45,4 +49,3 @@ typedef void (^KBStreamFetcherFinishHandler)(NSError *_Nullable error);
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
@property (nonatomic, assign) NSInteger decodedPrefixBytes; // 已解码并写入 sseTextBuffer 的字节数(SSE)
|
||||
@property (nonatomic, assign) NSInteger deliveredCharCount; // 已回传的字符数(非 SSE,用于做增量)
|
||||
@property (nonatomic, assign) BOOL hasEmitted; // 是否已经输出过正文(用于“首段删 1 个 \t”)
|
||||
@property (nonatomic, strong) NSMutableArray<NSString *> *pendingQueue; // 待回调的分片(节流输出)
|
||||
@property (nonatomic, strong) NSTimer *flushTimer; // 定时从队列取出一条回调
|
||||
@property (nonatomic, strong, nullable) NSError *finishError; // 结束时的错误(需要等队列清空再回调)
|
||||
@end
|
||||
|
||||
// 计算数据中以 UTF-8 编码可完整解码的“前缀字节长度”,避免切断多字节字符
|
||||
@@ -53,6 +56,9 @@ static NSUInteger kb_validUTF8PrefixLen(NSData *data) {
|
||||
_textEncoding = NSUTF8StringEncoding;
|
||||
_buffer = [NSMutableData data];
|
||||
_sseTextBuffer = [NSMutableString string];
|
||||
_pendingQueue = [NSMutableArray array];
|
||||
_flushInterval = 0.10;
|
||||
_splitLargeDeltasOnWhitespace = YES;
|
||||
}
|
||||
return self;
|
||||
}
|
||||
@@ -84,6 +90,9 @@ static NSUInteger kb_validUTF8PrefixLen(NSData *data) {
|
||||
self.decodedPrefixBytes = 0;
|
||||
self.deliveredCharCount = 0;
|
||||
self.hasEmitted = NO;
|
||||
[self.pendingQueue removeAllObjects];
|
||||
[self.flushTimer invalidate]; self.flushTimer = nil;
|
||||
self.finishError = nil;
|
||||
|
||||
self.task = [self.session dataTaskWithRequest:req];
|
||||
[self.task resume];
|
||||
@@ -99,6 +108,9 @@ static NSUInteger kb_validUTF8PrefixLen(NSData *data) {
|
||||
self.decodedPrefixBytes = 0;
|
||||
self.deliveredCharCount = 0;
|
||||
self.hasEmitted = NO;
|
||||
[self.pendingQueue removeAllObjects];
|
||||
[self.flushTimer invalidate]; self.flushTimer = nil;
|
||||
self.finishError = nil;
|
||||
}
|
||||
|
||||
#pragma mark - NSURLSessionDataDelegate
|
||||
@@ -172,9 +184,7 @@ static NSUInteger kb_validUTF8PrefixLen(NSData *data) {
|
||||
if (payload.length > 0 && [payload hasSuffix:@"\n"]) {
|
||||
[payload deleteCharactersInRange:NSMakeRange(payload.length - 1, 1)];
|
||||
}
|
||||
if (payload.length > 0) {
|
||||
[self emitChunk:payload];
|
||||
}
|
||||
if (payload.length > 0) { [self enqueueChunk:payload]; }
|
||||
}
|
||||
}
|
||||
return;
|
||||
@@ -186,7 +196,21 @@ static NSUInteger kb_validUTF8PrefixLen(NSData *data) {
|
||||
if (self.deliveredCharCount < (NSInteger)prefix.length) {
|
||||
NSString *delta = [prefix substringFromIndex:self.deliveredCharCount];
|
||||
self.deliveredCharCount = prefix.length;
|
||||
[self emitChunk:delta];
|
||||
if (self.splitLargeDeltasOnWhitespace && delta.length > 16) {
|
||||
// 按空格切词逐条回调(保留空格,使观感更自然)
|
||||
NSArray<NSString *> *parts = [delta componentsSeparatedByString:@" "];
|
||||
for (NSUInteger i = 0; i < parts.count; i++) {
|
||||
NSString *w = parts[i];
|
||||
if (w.length == 0) { [self enqueueChunk:@" "]; continue; }
|
||||
if (i + 1 < parts.count) {
|
||||
[self enqueueChunk:[w stringByAppendingString:@" "]];
|
||||
} else {
|
||||
[self enqueueChunk:w];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
[self enqueueChunk:delta];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,8 +236,14 @@ static NSUInteger kb_validUTF8PrefixLen(NSData *data) {
|
||||
}
|
||||
}
|
||||
|
||||
if (self.onFinish) dispatch_async(dispatch_get_main_queue(), ^{ self.onFinish(error); });
|
||||
[self cancel];
|
||||
// 若队列还有待输出内容,等队列清空再回调 finish
|
||||
if (self.pendingQueue.count > 0) {
|
||||
self.finishError = error;
|
||||
[self startFlushTimerIfNeeded];
|
||||
} else {
|
||||
if (self.onFinish) dispatch_async(dispatch_get_main_queue(), ^{ self.onFinish(error); });
|
||||
[self cancel];
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - Helpers
|
||||
@@ -243,5 +273,34 @@ static NSUInteger kb_validUTF8PrefixLen(NSData *data) {
|
||||
self.hasEmitted = YES;
|
||||
}
|
||||
|
||||
@end
|
||||
#pragma mark - Queue/Flush
|
||||
|
||||
- (void)enqueueChunk:(NSString *)s {
|
||||
if (s.length == 0) return;
|
||||
[self.pendingQueue addObject:s];
|
||||
[self startFlushTimerIfNeeded];
|
||||
}
|
||||
|
||||
- (void)startFlushTimerIfNeeded {
|
||||
if (self.flushTimer) return;
|
||||
__weak typeof(self) weakSelf = self;
|
||||
self.flushTimer = [NSTimer scheduledTimerWithTimeInterval:MAX(0.01, self.flushInterval)
|
||||
repeats:YES
|
||||
block:^(NSTimer * _Nonnull t) {
|
||||
__strong typeof(weakSelf) self = weakSelf; if (!self) { [t invalidate]; return; }
|
||||
if (self.pendingQueue.count == 0) {
|
||||
[t invalidate]; self.flushTimer = nil;
|
||||
if (self.finishError || self.finishError == nil) {
|
||||
NSError *err = self.finishError; self.finishError = nil;
|
||||
if (self.onFinish) dispatch_async(dispatch_get_main_queue(), ^{ self.onFinish(err); });
|
||||
[self cancel];
|
||||
}
|
||||
return;
|
||||
}
|
||||
NSString *first = self.pendingQueue.firstObject;
|
||||
[self.pendingQueue removeObjectAtIndex:0];
|
||||
[self emitChunk:first];
|
||||
}];
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
@@ -56,6 +56,9 @@
|
||||
_scrollView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
|
||||
_scrollView.alwaysBounceVertical = YES;
|
||||
_scrollView.showsVerticalScrollIndicator = YES;
|
||||
// 留一点底部余量,避免最后一行视觉上“贴底被遮住”的感觉
|
||||
_scrollView.contentInset = UIEdgeInsetsMake(0, 0, 6, 0);
|
||||
_scrollView.scrollIndicatorInsets = _scrollView.contentInset;
|
||||
[self addSubview:_scrollView];
|
||||
}
|
||||
|
||||
@@ -171,7 +174,6 @@
|
||||
label.textColor = self.labelTextColor;
|
||||
label.userInteractionEnabled = YES;
|
||||
label.text = @"";
|
||||
label.backgroundColor = [UIColor redColor];
|
||||
UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(handleLabelTap:)];
|
||||
[label addGestureRecognizer:tap];
|
||||
|
||||
@@ -196,8 +198,10 @@
|
||||
NSString *trimmed = [current.text stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
|
||||
current.text = trimmed;
|
||||
self.buffer = trimmed;
|
||||
[self layoutLabelsForCurrentWidth];
|
||||
}
|
||||
// 无论是否裁剪,都重新布局并滚动到底部,避免最后一段显示不全
|
||||
[self layoutLabelsForCurrentWidth];
|
||||
[self scrollToBottomIfNeeded];
|
||||
}
|
||||
|
||||
- (void)layoutSubviews {
|
||||
@@ -247,7 +251,8 @@
|
||||
CGFloat contentHeight = self.scrollView.contentSize.height;
|
||||
if (contentHeight > height && height > 0) {
|
||||
CGPoint bottomOffset = CGPointMake(0, contentHeight - height);
|
||||
[self.scrollView setContentOffset:bottomOffset animated:YES];
|
||||
// 使用无动画滚动,避免在短时间内多次追加时“最后一行没完全跟上”的错觉
|
||||
[self.scrollView setContentOffset:bottomOffset animated:NO];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user