This commit is contained in:
2025-11-12 14:36:15 +08:00
parent afc44cb471
commit 1dbe04cdf9
3 changed files with 78 additions and 11 deletions

View File

@@ -33,6 +33,10 @@ typedef void (^KBStreamFetcherFinishHandler)(NSError *_Nullable error);
@property (nonatomic, assign) BOOL treatSlashTAsTab; // 默认 YES将“/t”替换为“\t” @property (nonatomic, assign) BOOL treatSlashTAsTab; // 默认 YES将“/t”替换为“\t”
@property (nonatomic, assign) BOOL trimLeadingTabOnce; // 默认 YES首次正文起始的“\t”删一个忽略前导空白 @property (nonatomic, assign) BOOL trimLeadingTabOnce; // 默认 YES首次正文起始的“\t”删一个忽略前导空白
@property (nonatomic, assign) NSTimeInterval requestTimeout; // 默认 30s @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; @property (nonatomic, copy, nullable) KBStreamFetcherChunkHandler onChunk;
@@ -45,4 +49,3 @@ typedef void (^KBStreamFetcherFinishHandler)(NSError *_Nullable error);
@end @end
NS_ASSUME_NONNULL_END NS_ASSUME_NONNULL_END

View File

@@ -14,6 +14,9 @@
@property (nonatomic, assign) NSInteger decodedPrefixBytes; // sseTextBuffer SSE @property (nonatomic, assign) NSInteger decodedPrefixBytes; // sseTextBuffer SSE
@property (nonatomic, assign) NSInteger deliveredCharCount; // SSE @property (nonatomic, assign) NSInteger deliveredCharCount; // SSE
@property (nonatomic, assign) BOOL hasEmitted; // 1 \t @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 @end
// UTF-8 // UTF-8
@@ -53,6 +56,9 @@ static NSUInteger kb_validUTF8PrefixLen(NSData *data) {
_textEncoding = NSUTF8StringEncoding; _textEncoding = NSUTF8StringEncoding;
_buffer = [NSMutableData data]; _buffer = [NSMutableData data];
_sseTextBuffer = [NSMutableString string]; _sseTextBuffer = [NSMutableString string];
_pendingQueue = [NSMutableArray array];
_flushInterval = 0.10;
_splitLargeDeltasOnWhitespace = YES;
} }
return self; return self;
} }
@@ -84,6 +90,9 @@ static NSUInteger kb_validUTF8PrefixLen(NSData *data) {
self.decodedPrefixBytes = 0; self.decodedPrefixBytes = 0;
self.deliveredCharCount = 0; self.deliveredCharCount = 0;
self.hasEmitted = NO; self.hasEmitted = NO;
[self.pendingQueue removeAllObjects];
[self.flushTimer invalidate]; self.flushTimer = nil;
self.finishError = nil;
self.task = [self.session dataTaskWithRequest:req]; self.task = [self.session dataTaskWithRequest:req];
[self.task resume]; [self.task resume];
@@ -99,6 +108,9 @@ static NSUInteger kb_validUTF8PrefixLen(NSData *data) {
self.decodedPrefixBytes = 0; self.decodedPrefixBytes = 0;
self.deliveredCharCount = 0; self.deliveredCharCount = 0;
self.hasEmitted = NO; self.hasEmitted = NO;
[self.pendingQueue removeAllObjects];
[self.flushTimer invalidate]; self.flushTimer = nil;
self.finishError = nil;
} }
#pragma mark - NSURLSessionDataDelegate #pragma mark - NSURLSessionDataDelegate
@@ -172,9 +184,7 @@ static NSUInteger kb_validUTF8PrefixLen(NSData *data) {
if (payload.length > 0 && [payload hasSuffix:@"\n"]) { if (payload.length > 0 && [payload hasSuffix:@"\n"]) {
[payload deleteCharactersInRange:NSMakeRange(payload.length - 1, 1)]; [payload deleteCharactersInRange:NSMakeRange(payload.length - 1, 1)];
} }
if (payload.length > 0) { if (payload.length > 0) { [self enqueueChunk:payload]; }
[self emitChunk:payload];
}
} }
} }
return; return;
@@ -186,7 +196,21 @@ static NSUInteger kb_validUTF8PrefixLen(NSData *data) {
if (self.deliveredCharCount < (NSInteger)prefix.length) { if (self.deliveredCharCount < (NSInteger)prefix.length) {
NSString *delta = [prefix substringFromIndex:self.deliveredCharCount]; NSString *delta = [prefix substringFromIndex:self.deliveredCharCount];
self.deliveredCharCount = prefix.length; 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); }); // finish
[self cancel]; 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 #pragma mark - Helpers
@@ -243,5 +273,34 @@ static NSUInteger kb_validUTF8PrefixLen(NSData *data) {
self.hasEmitted = YES; 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

View File

@@ -56,6 +56,9 @@
_scrollView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; _scrollView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
_scrollView.alwaysBounceVertical = YES; _scrollView.alwaysBounceVertical = YES;
_scrollView.showsVerticalScrollIndicator = YES; _scrollView.showsVerticalScrollIndicator = YES;
//
_scrollView.contentInset = UIEdgeInsetsMake(0, 0, 6, 0);
_scrollView.scrollIndicatorInsets = _scrollView.contentInset;
[self addSubview:_scrollView]; [self addSubview:_scrollView];
} }
@@ -171,7 +174,6 @@
label.textColor = self.labelTextColor; label.textColor = self.labelTextColor;
label.userInteractionEnabled = YES; label.userInteractionEnabled = YES;
label.text = @""; label.text = @"";
label.backgroundColor = [UIColor redColor];
UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(handleLabelTap:)]; UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(handleLabelTap:)];
[label addGestureRecognizer:tap]; [label addGestureRecognizer:tap];
@@ -196,8 +198,10 @@
NSString *trimmed = [current.text stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; NSString *trimmed = [current.text stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
current.text = trimmed; current.text = trimmed;
self.buffer = trimmed; self.buffer = trimmed;
[self layoutLabelsForCurrentWidth];
} }
//
[self layoutLabelsForCurrentWidth];
[self scrollToBottomIfNeeded];
} }
- (void)layoutSubviews { - (void)layoutSubviews {
@@ -247,7 +251,8 @@
CGFloat contentHeight = self.scrollView.contentSize.height; CGFloat contentHeight = self.scrollView.contentSize.height;
if (contentHeight > height && height > 0) { if (contentHeight > height && height > 0) {
CGPoint bottomOffset = CGPointMake(0, contentHeight - height); CGPoint bottomOffset = CGPointMake(0, contentHeight - height);
[self.scrollView setContentOffset:bottomOffset animated:YES]; // 使
[self.scrollView setContentOffset:bottomOffset animated:NO];
} }
} }