深入理解iOS圖文混排原理并自定義圖文控件

轉自:infoQ : 深入理解 iOS 圖文混排原理并自定義圖文控件

GitHub地址:CJLabel
iOS開發中一般用UILabel來展示文字、UIImageView用來顯示圖片、UIButton用于簡單的圖文點擊響應事件,稍復雜一點的可以借助NSAttributedString來實現圖文混排需求,又或者將圖文內容轉換為HTML由WKWebView(UIWebView)來展示。然而以上方案都有各自的局限性:UILabel繪制NSAttributedString不能靈活定位文本內的點擊錨點區域,轉換為HTML展示則帶來Native與Web端交互成本以及WKWebView自身的性能問題。

那么,是否能有一種控件,在滿足富文本圖文混排的同時還能響應自定義錨點點擊事件?要實現以上需求,我們首先從iOS圖文展示原理說起。

圖文繪制架構

iOS7之后的圖文繪制架構如下圖所示,越往上封裝程度越高,但可定制程度也越低,本文涉及講解的主要是CoreText以及CoreGraphics層級 。

image

CoreGraphics

從下往上說,首先是CoreGraphics。這是一個C語言接口的核心圖形庫,而且它是跨平臺的類庫,iOS和macOS系統均可使用。雖然它很偏底層,但很多情況下其實你已經使用過它了:比如CGAffineTransform用于形變,CGBitmapContext用于截圖或者圖片繪制,CGContext用于獲取上下文進行直線、曲線、不規則圖形繪制等。

這里著重說明下CGContext上下文。上下文類似于進行繪畫時候的畫布,使用UIGraphicsGetCurrentContext()可以快捷得到當前上下文,同時需要注意在CoreText下坐標系的原點為視圖的左下角,x軸向右為正方向,y軸向上為正方向。而UIKit坐標系的原點是視圖的左上角,x軸向右為正方向,y軸向下為正方向,所以我們在進行圖文繪制前需要進行坐標反轉,如圖所示:

image
//獲取上下文
CGContextRef c = UIGraphicsGetCurrentContext();
// 將當前圖形狀態推入堆棧
CGContextSaveGState(c);
// 設置字形變換矩陣為CGAffineTransformIdentity,也就是說每一個字形都不做圖形變換
CGContextSetTextMatrix(c, CGAffineTransformIdentity);
// 坐標轉換,UIKit 坐標原點在左上角,CoreText 坐標原點在左下角
CGContextTranslateCTM(c, 0.0f, insetRect.size.height);
CGContextScaleCTM(c, 1.0f, -1.0f);
// TODO:進行圖文繪制操作
//...
// 繪制完成,將堆棧頂部的狀態彈出,返回到之前的圖形狀態
CGContextRestoreGState(c);

以上使用CoreGraphics進行圖文繪制的過程,可以在drawRect:drawTextInRect:等相關方法中進行操作。

CoreText框架

CoreText是iOS中用于文本繪制的引擎,其位于UIKit中和CoreGraphics/Quartz之間。查看開發文檔,可以看到CoreText架構主要包含以下類,其中標紅部分是圖文繪制需要使用到的相關類,我們逐個介紹。

CoreText

CTFramesetter

CTFramesetter是管理生成CTFrame的工廠類,其中記錄了需要繪制的文本內容中不同字符串對應的富文本屬性(加粗、顏色、字號等),通過NSAttributedString可生成CTFrameSetter。

NSMutableAttributedString *attributedStr = [[NSMutableAttributedString alloc] initWithString:text attributes:attributes];
//生成CTFramesetter
CTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)attributedStr);
CFRelease(framesetter);

CTFrame

CTFrame描述了總的文本繪制區域的frame,通過它你可以得到在指定區域內繪制的文本一共有多少行。

CGRect rect = CGRectMake(0, 0, 100, 100);
//生成繪制區域路徑
CGMutablePathRef path = CGPathCreateMutable();
CGPathAddRect(path, NULL, rect);
//生成CTFrame
CTFrameRef frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, [attributedStr length]), path, NULL);

//獲取一共有多少行
CFArrayRef lines = CTFrameGetLines(frame);

CFRelease(frame);
CGPathRelease(path);

CTLine

CTLine記錄了需要繪制的單行文本信息,通過它你可以得到當前行的上行高、下行高以及行間距等信息。

//獲取第一行信息
CTLineRef line = CFArrayGetValueAtIndex(lines, 0);
//上行高、下行高、行間距
CGFloat lineAscent = 0.0f, lineDescent = 0.0f, lineLeading = 0.0f;
//獲取行寬、行高信息
CGFloat lineWidth = CTLineGetTypographicBounds(line, &lineAscent, &lineDescent, &lineLeading);

關于行文本的上下行高、行間距、原點、基線等的說明,可參照下圖:

行高

系統繪制文本的時候,首先會以基線(Baseline)為基準,從當前行的基線最左側的原點(Origin)開始,計算得到上行高(Ascent),下行高(Descent),不同行之間的行間距(Leading),以及行寬信息。

CTRun

CTRun描述了單行文本中具有相同富文本屬性的字符實體,每一行文字中可能有多個CTRun,也有可能只包含一個CTRun。如下圖,這行文字中包含三個CTRun,分別為:這是 一段 測試數據

CTRun說明

與CTLine一樣,同樣可以計算得到單個CTRun的繪制區域大小。

//初始化CTRun的區域大小為CGRectZero
CGRect runBounds = CGRectZero;
//初始化CTRun的上行高、下行高、行間距
CGFloat runAscent = 0.0f, runDescent = 0.0f, runLeading = 0.0f;
//計算得到上下行高、行間距以及CTRun繪制區域寬度
runBounds.size.width = (CGFloat)CTRunGetTypographicBounds(glyphRun, CFRangeMake(0, 0), &runAscent, &runDescent, &runLeading);
//計算高度,注意下行高為負數的情況
CGFloat runHeight = runAscent + fabs(runDescent);
runBounds.size.height = runHeight;

CTRunDelegate

CTRunDelegate用于圖文混排時候的圖片繪制,因為CoreText本身并不能進行圖文混排,但是可以使用CTRunDelegate在需要顯示圖片的地方添加占位符,當CoreText繪制到該位置的時候,會觸發CTRunDelegate代理,在代理方法中可以獲取到該區域的大小以及圖片信息,然后調用 CGContextDrawImage(c, runBounds, image.CGImage) 繪制圖片即可。

NSDictionary *imgInfoDic = @{kCJImage:image,//需要繪制的圖片
                             kCJImageWidth:@(size.width),//需要繪制的圖片區域寬度
                             kCJImageHeight:@(size.height),//需要繪制的圖片區域高度};
    
//創建CTRunDelegateRef并設置回調函數
CTRunDelegateCallbacks imageCallbacks;
imageCallbacks.version = kCTRunDelegateVersion1;
imageCallbacks.dealloc = RunDelegateDeallocCallback;
imageCallbacks.getWidth = RunDelegateGetWidthCallback;//圖片區域寬度回調
imageCallbacks.getAscent = RunDelegateGetAscentCallback;//圖片區域上行高回調
imageCallbacks.getDescent = RunDelegateGetDescentCallback;//圖片區域下行高回調
CTRunDelegateRef runDelegate = CTRunDelegateCreate(&imageCallbacks, (__bridge void *)imgInfoDic);
//初始化空白占位字符
unichar imgReplacementChar = 0xFFFC;
NSString *imgReplacementString = [NSString stringWithCharacters:&imgReplacementChar length:1];
//插入圖片 空白占位符
NSMutableString *imgPlaceholderStr = [[NSMutableString alloc]initWithCapacity:3];
[imgPlaceholderStr appendString:imgReplacementString];
NSRange imgRange = NSMakeRange(0, imgPlaceholderStr.length);
NSMutableAttributedString *imageAttributedString = [[NSMutableAttributedString alloc] initWithString:imgPlaceholderStr];
//將CTRunDelegate記錄到NSMutableAttributedString的富文本信息中
[imageAttributedString addAttribute:(NSString *)kCTRunDelegateAttributeName value:(__bridge id)runDelegate range:imgRange];
//kCJImageAttributeName為自定義的記錄圖片信息的富文本屬性
[imageAttributedString addAttribute:kCJImageAttributeName value:imgInfoDic range:imgRange];

看完上面的概念介紹,相信你已經對iOS的圖文繪制原理有了基礎的認識,以上各個類的關聯關系如圖所示:

CoreText說明

完整的繪制流程如下:

繪制流程

自定義圖文混排控件

自定義圖文混排控件,可以基于UILabel來實現,UILabel本身已支持NSAttributedString富文本展示,我們只需在原有基礎上擴展指定字符區域背景色、插入圖片(或自定義view)展示、指定錨點點擊響應事件、點擊時候的字符高亮展示等功能即可。

繪制關鍵點說明

首先在設置NSAttributedString富文本屬性的時候,增加自定義屬性。NSAttributedString的富文本屬性Attributes其實就是字典,那么可以在其中添加自定義的key-value配置,類似以下說明:

/**
 刪除線寬度。值為NSNumber。默認 `0.0f`,表示無刪除線
 */
extern NSString * const kCJStrikethroughStyleAttributeName;
/**
 刪除線顏色。值為UIColor。默認 `[UIColor blackColor]`。
 */
extern NSString * const kCJStrikethroughColorAttributeName;
/**
 對NSAttributedString文本設置錨點屬性時候的唯一標識
 */
extern NSString * const kCJLinkStringIdentifierAttributesName;

第二步就是在遍歷獲取CTRun時,將屬于錨點的CTRun的frame區域信息記錄起來,同時還要記錄該錨點對應的擴展參數,以及合并具有相同屬性的CTRun。

第三步是繪制字符,如果包含自定義屬性,那么需要調用CoreGraphics的相關方法進行擴展屬性的設置,比如先填充背景色CGContextSetFillColorWithColor(c,color); 再繪制文字CTRunDraw(runRef, c, CFRangeMake(0, 0)); 再添加邊框線、刪除線CGContextSetStrokeColorWithColor(c,color); 你可以把這個過程想象是成在一張畫布上繪畫,繪制時候需要注意不同圖層的層級關系,不然上面的圖層會將下面的圖層覆蓋。

最后一步是圖片展示,如果CTRun是包含CTRunDelegate的顯示區域,那么系統會將你設置好大小的區域空白出來,你只需在該位置上畫出圖片即可:CGContextDrawImage(c, runBounds, image.CGImage);另外我還在此基礎上做了擴展,不單單支持圖文混排,還可以在指定區域上插入任意UIView。原理是同樣借助CTRunDelegate在對應位置上預留出指定大小的空白區域,然后將需要插入的UIView存儲在NSAttributedString的Attributes屬性中,當繪制到該位置時只需調用[self addSubview:view];即可。

點擊響應

UILabel繼承自UIView和UIResponder,那么可以基于iOS的事件響應鏈機制來實現錨點點擊事件。

重寫hitTest: withEvent:方法,在其中判斷是否需要響應點擊事件,否則將響應事件向下傳遞。

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
    // ![self linkAtPoint:point extendsLinkTouchArea:NO]表示不在錨點點擊范圍內
    if (![self linkAtPoint:point extendsLinkTouchArea:NO] || !self.userInteractionEnabled || self.hidden || self.alpha < 0.01) {
        //如果支持選擇復制
        if (self.enableCopy) {
            return [super hitTest:point withEvent:event];
        }else{
            return nil;
        }
    }
    return self;
}

至于如何判斷是否在錨點點擊范圍內,可參照linkAtPoint: extendsLinkTouchArea:偽函數說明:

- (CJGlyphRunStrokeItem *)linkAtPoint:(CGPoint)point extendsLinkTouchArea:(BOOL)extendsLinkTouchArea {
    // CJGlyphRunStrokeItem 對應 CTRun,其中記錄了字符區域(bounds)的大小
    CJGlyphRunStrokeItem *resultItem = nil;
    // _linkStrokeItemArray 表示記錄了所有錨點信息的數組
    for (CJGlyphRunStrokeItem *item in _linkStrokeItemArray) {
        if (CGRectContainsPoint(item.bounds, point)) {
            resultItem = item;
        }
    }
    return resultItem;
}

重寫touches系列方法,首先在touchesBegan: withEvent:中判斷是否點擊了錨點區域,如果是則觸發重繪以達到點擊高亮效果。

- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
    UITouch *touch = [touches anyObject];
    CJGlyphRunStrokeItem *item = [self linkAtPoint:[touch locationInView:self] extendsLinkTouchArea:self.extendsLinkTouchArea];
    //點擊錨點存在
    if (item) {
        //TODO: 觸發重繪操作,達到點擊錨點高亮效果
    }else{
        [super touchesBegan:touches withEvent:event];
    }
}

然后在touchesEnded: withEvent:中處理點擊事件的響應操作

- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event {
    //  如果是長按點擊,交由長按點擊手勢UILongPressGestureRecognizer響應
    if (_longPress) {
        [super touchesEnded:touches withEvent:event];
    }else{
        if (_currentClickRunStrokeItem) {
            // 如果當前是點擊錨點事件,拋出點擊回調事件
            if (self.delegate && [self.delegate respondsToSelector:@selector(CJLable:didClickLink:)]) {
                [self.delegate CJLable:self didClickLink:linkModel];
            }
            // TODO: 再次重繪,還原點擊前的文本顯示
        }
        else {
            [super touchesEnded:touches withEvent:event];
        }
    }
}

這里需要注意一下,如果是雙擊點擊事件或者長按點擊事件,那么在touches系列的回調方法中是不能處理的,交互處理應該放到UITapGestureRecognizerUILongPressGestureRecognizer的響應方法中判斷。另外手勢事件的響應方法中是無法得到當前點擊位置的點坐標CGPoint的,這里用到了比較取巧的方式(通過Rumtime關聯屬性)達到了判斷點擊響應的效果。

// 在此時剛接收到手勢事件的回調中,將UITouch關聯到 UIGestureRecognizer實例
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch {
    if (gestureRecognizer == self.longPressGestureRecognizer) {
        objc_setAssociatedObject(self.longPressGestureRecognizer, &kAssociatedUITouchKey, touch, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
    }
    else if (gestureRecognizer == self.doubleTapGes) {
        objc_setAssociatedObject(self.doubleTapGes, &kAssociatedUITouchKey, touch, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
    }
    return YES;
}
// 雙擊點擊事件
- (void)tapTwoAct:(UITapGestureRecognizer *)sender {
    // Runtime屬性關聯,獲取到對應的UITouch,并計算得到點擊坐標:[touch locationInView:self]
    UITouch *touch = objc_getAssociatedObject(self.doubleTapGes, &kAssociatedUITouchKey);
    CJGlyphRunStrokeItem *item = [self linkAtPoint:[touch locationInView:self] extendsLinkTouchArea:self.extendsLinkTouchArea];
    if (item) {
        //TODO: 如果當前是雙擊點擊錨點事件,拋出點擊回調事件
        if (self.delegate && [self.delegate respondsToSelector:@selector(CJLable:didClickLink:)]) {
            [self.delegate CJLable:self didClickLink:linkModel];
        }
        // TODO: 再次重繪,還原點擊前的文本顯示
    }
    //如果不是點擊錨點且開啟了選擇復制功能
    else {
        if (self.enableCopy) {
            //TODO: 顯示選擇復制提示視圖
        }
    }
}
// 長按點擊事件
- (void)longPressGestureDidFire:(UILongPressGestureRecognizer *)sender {
    // Runtime屬性關聯,獲取到對應的UITouch,并計算得到點擊坐標:[touch locationInView:self]
    UITouch *touch = objc_getAssociatedObject(self.longPressGestureRecognizer, &kAssociatedUITouchKey);
    CGPoint point = [touch locationInView:self];
    BOOL isLinkItem = [self containslinkAtPoint:[touch locationInView:self]];
    switch (sender.state) {
        //TODO: 如果當前是長按錨點事件,拋出長按回調事件
        case UIGestureRecognizerStateBegan: {
            if (isLinkItem) {
                if (self.delegate && [self.delegate respondsToSelector:@selector(CJLable:didLongPressLink:)]) {
                        [self.delegate CJLable:self didLongPressLink:linkModel];
                    }
            }
            //如果不是點擊錨點且開啟了選擇復制功能
            else {
                if (self.enableCopy) {
                    //TODO: 長按顯示放大鏡
                }
            }
        }
            break;
        }
        case UIGestureRecognizerStateEnded:{
            //TODO: 再次重繪,還原長按前的文本顯示
            //如果支持復制
            if (self.enableCopy) {
                //TODO: 顯示選擇復制提示視圖
            }
            break;
        }
        case UIGestureRecognizerStateChanged:
            //如果支持復制
            if (self.enableCopy) {
                //TODO: 更新放大鏡位置,以及更新選擇復制區域
            }
            break;
        default:
            break;
    }
}

來看一下自定義點擊控件的效果圖

點擊響應

選擇、復制

自定義圖文混排控件還可以支持選擇復制功能,當然這里說的選擇復制不是指點擊喚起UIMenuController菜單,然后出現復制剪切選項,點擊則只能復制所有文本。那樣的例子網上已經有很多,沒有必要在這里再大費周章地羅列說明。 其需要具備的是類似于UITextView或UIWebView那樣雙擊或長按,可出現拷貝、選擇、全選選項,同時選中字符左右出現指示大頭針,拖動則有放大鏡提示當前選中的字符,并且要盡量做到與系統行為一致。

選擇復制

需求細化后選擇復制的要點主要包含以下:

  • 選中字符后出現拷貝、選擇 全選菜單,這個使用系統的UIMenuController功能即可實現,不存在難點問題。
  • 對于選中的文字,起始要有大頭針標識,中間填充淺藍色背景,而且這一部分區域會是一塊不規則多邊形。系統沒有提供現成可復用的對應UI控件,但只要我們能夠判斷到選中區域,就能在左右畫上大頭針,中間填充顏色,所以這一塊也不存在問題。
  • 拖動選擇的過程中,出現放大鏡來提示選中字符的更改。在能夠準確獲取到當前觸摸點坐標的前提下,只需要將觸摸點周圍區域的圖層截取并作CGContextScaleCTM縮放,然后再將放大后的圖層顯示出來即可,所以這個也是可以實現的。
  • 最后便是重點了,如何判斷每一個字符對應的frame坐標位置,并要求在手指移動時能夠準確判斷選擇區域的變化。

前面已經講到,單行文本中具有相同富文本屬性的字符會被繪制到同一個CTRun中,而通過CTRun可以計算得到它的frame大小。那么重點則變成如何使得每一個字符(圖片或插入UIView)對應一個CTRun

解決很簡單:只要保證NSAttributedString中每一個字符的Attributes屬性不一樣就可以了。我開始的做法是添加一個自定義屬性kCJIndexAttributesName,然后給每個字符存儲不同的index值,并且在全部圖文遍歷繪制過一次后將kCJIndexAttributesName移除,這樣在后續的重繪中就會減少CTRun的拆分數量,提高了效率。

然而,理想很美好,現實很打擊。就算自定義屬性kCJIndexAttributesName移除了,可CTRun還是會被拆分為單個字符,但是如果使用系統自帶的屬性則不會如此。無奈只能從系統方法中尋找解決思路,幸好發現了NSLinkAttributeName屬性,這是UITextView中用來設置http鏈接的擴展屬性,存儲的對象是NSURLNSString類型,而UILabel默認是不支持http鏈點的,使用NSLinkAttributeName屬性可以最大限度的降低UILabel對默認NSAttributedString展示的影響。同時為了更好的判斷計算,我將存儲的對象改為NSURL的子類CJCTRunUrl

//給每一個字符設置index值,enableCopy=YES時生效
__block NSInteger index = 0;
[attText.string enumerateSubstringsInRange:NSMakeRange(0, [attText length]) options:NSStringEnumerationByComposedCharacterSequences usingBlock:
 ^(NSString *substring, NSRange substringRange, NSRange enclosingRange, BOOL *stop) {
     CJCTRunUrl *runUrl = nil;
     if (!runUrl) {
         NSString *urlStr = [NSString stringWithFormat:@"https://www.CJLabel%@",@(index)];
         runUrl = [CJCTRunUrl URLWithString:urlStr];
     }
     runUrl.index = index;
     runUrl.rangeValue = [NSValue valueWithRange:substringRange];
     [attText addAttribute:NSLinkAttributeName
                     value:runUrl
                     range:substringRange];
     index++;
 }];

選擇復制視圖展示的交互邏輯則在雙擊或長按手勢中實現,前面 點擊響應 的偽代碼示意中已經說明。另外講解一下選擇復制視圖的實現細節:

其中的拷貝、選擇、全選菜單使用系統提供的UIMenuController實現,在雙擊或長按時只要將它顯示到手指點擊對應的位置上就行。

放大鏡則是自定義UIView,并在上面添加一個CALayer,再在CALayer上根據更新的點坐標做放大效果,CALayer的放大境處理邏輯如下:

- (void)drawInContext:(CGContextRef)ctx {
    CGContextTranslateCTM(ctx, self.frame.size.width/2, self.frame.size.height/2);
    CGContextScaleCTM(ctx, 1.40, 1.40);
    //self.pointToMagnify是更新的放大點坐標
    CGContextTranslateCTM(ctx, -1 * self.pointToMagnify.x, -1 * self.pointToMagnify.y);
    [CJkeyWindow().layer renderInContext:ctx];
    CJkeyWindow().layer.contents = (id)nil;
}

大頭針包含的提示用戶選中區域,同樣由自定義UIView實現,在自定義view上面填充顏色以及在起始結束位置畫出大頭針,其中的填充顏色區域包含三部分headRect middleRect tailRect

選擇區域

這三部分存在任意組合的情況,填充顏色的時候要對這三部分區分開來分別進行填充。因為有可能存在只有headRect middleRect 或只有middleRect tailRect,又或者只有 middleRect 的情況,而且填充顏色使用的是CoreGraphics中的繪圖API:

CGContextRef ctx = UIGraphicsGetCurrentContext();
//填充的背景色
UIColor *backColor = CJUIRGBColor(0,84,166,0.2);
[backColor set];
CGContextAddRect(ctx, self.headRect);
CGContextStrokePath(ctx);

接下來便是如何顯示這三個選擇復制相關的視圖了,一開始我只是簡單的將它們添加到Label上面來統一管理,但這樣會存在一個問題。那就是當頁面中存在多個Label,并且對每個Label分別執行選擇復制操作時,那么不同的label上都會出現選擇復制視圖,這是與系統的默認行為是不一致的。權衡之后將以上三個視圖的顯示作為單例處理,全局只初始化一次,避免了重復初始化的開銷。并且將它添加到UIWindow層,這樣在不同的Label之間進行選擇復制時,也只會顯示一個選擇復制視圖。

寫在最后

以上便是iOS圖文繪制原理以及自定義圖文控件的說明,關鍵點是充分理解你看到的每一個字符在底層繪制顯示的時候與CTFrame CTLine CTRun等實體類的對應關系,并借助其計算得到每一個字符的區域frame信息,有了區域frame信息便能夠擴展實現各種自定義功能(點擊響應、插入圖片、選擇拷貝等)。

全文完,更多的實現可以查看源碼CJLabel。

作者簡介:lele8446,iOS開發深耕者,愛好分享、深入探討有溫度的內容,GitHub地址。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,533評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,055評論 3 414
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事?!?“怎么了?”我有些...
    開封第一講書人閱讀 175,365評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,561評論 1 307
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,346評論 6 404
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,889評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 42,978評論 3 439
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,118評論 0 286
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,637評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,558評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,739評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,246評論 5 355
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 43,980評論 3 346
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,362評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,619評論 1 280
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,347評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,702評論 2 370

推薦閱讀更多精彩內容