【AICon】探索八个行业创新案例,教你在教育、金融、医疗、法律等领域实践大模型技术! >>> 了解详情
写点什么

深入理解 iOS 图文混排原理并自定义图文控件

  • 2019-10-28
  • 本文字数:9267 字

    阅读完需:约 30 分钟

深入理解iOS图文混排原理并自定义图文控件

iOS 开发中一般用 UILabel 来展示文字、UIImageView 用来显示图片、UIButton 用于简单的图文点击响应事件,稍复杂一点的可以借助NSAttributedString来实现图文混排需求,又或者将图文内容转换为 HTML 由 WKWebView(UIWebView)来展示。然而以上方案都有各自的局限性:UILabel 绘制 NSAttributedString 不能灵活定位文本内的点击锚点区域,转换为 HTML 展示则带来 Native 与 Web 端交互成本以及 WKWebView 自身的性能问题。


那么,是否能有一种控件,在满足富文本图文混排的同时还能响应自定义锚点点击事件?要实现以上需求,我们首先从 iOS 图文展示原理说起。

图文绘制架构

iOS7 之后的图文绘制架构如下图所示,越往上封装程度越高,但可定制程度也越低,本文涉及讲解的主要是CoreText以及CoreGraphics层级 。


CoreGraphics

从下往上说,首先是 CoreGraphics。这是一个 C 语言接口的核心图形库,而且它是跨平台的类库,iOS 和 macOS 系统均可使用。虽然它很偏底层,但很多情况下其实你已经使用过它了:比如 CGAffineTransform 用于形变,CGBitmapContext 用于截图或者图片绘制,CGContext 用于获取上下文进行直线、曲线、不规则图形绘制等。


这里着重说明下 CGContext 上下文。上下文类似于进行绘画时候的画布,使用UIGraphicsGetCurrentContext()可以快捷得到当前上下文,同时需要注意在 CoreText 下坐标系的原点为视图的左下角,x 轴向右为正方向,y 轴向上为正方向。而 UIKit 坐标系的原点是视图的左上角,x 轴向右为正方向,y 轴向下为正方向,所以我们在进行图文绘制前需要进行坐标反转,如图所示:



//获取上下文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 架构主要包含以下类,其中标红部分是图文绘制需要使用到的相关类,我们逐个介绍。


CTFramesetter

CTFramesetter 是管理生成 CTFrame 的工厂类,其中记录了需要绘制的文本内容中不同字符串对应的富文本属性(加粗、颜色、字号等),通过 NSAttributedString 可生成 CTFrameSetter。


NSMutableAttributedString *attributedStr = [[NSMutableAttributedString alloc] initWithString:text attributes:attributes];//生成CTFramesetterCTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)attributedStr);CFRelease(framesetter);
复制代码

CTFrame

CTFrame 描述了总的文本绘制区域的frame,通过它你可以得到在指定区域内绘制的文本一共有多少行。


CGRect rect = CGRectMake(0, 0, 100, 100);//生成绘制区域路径CGMutablePathRef path = CGPathCreateMutable();CGPathAddRect(path, NULL, rect);//生成CTFrameCTFrameRef 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,分别为:这是 一段 测试数据



与 CTLine 一样,同样可以计算得到单个 CTRun 的绘制区域大小。


//初始化CTRun的区域大小为CGRectZeroCGRect 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 的图文绘制原理有了基础的认识,以上各个类的关联关系如图所示:



完整的绘制流程如下:


自定义图文混排控件

自定义图文混排控件,可以基于 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地址。


2019-10-28 18:264752

评论 3 条评论

发布
用户头像
写的不错
2019-11-05 13:37
回复
用户头像
厉害了!!
2019-10-29 19:28
回复
多谢波哥
2019-10-29 21:51
回复
没有更多了
发现更多内容

区块链蕴含的变革力

CECBC

在线JSON转HTML工具

入门小站

工具

SSH免登陆

Mike

死锁终结者:顺序锁和轮询锁!

王磊

Java 死锁 8月日更

“腾讯待办”小程序正式发布!初次见面,请多指教。

我为什么坚持六点起床

月哥

区块链溯源:重塑咖啡产业链

CECBC

ShardingSphere JDBC 语句执行初探

源码 ShardingSphere

审计挖掘之CNVD通用漏洞

网络安全学海

黑客 网络安全 信息安全 WEB安全 漏洞挖掘

JavaScript 中 Math.random() 生成随机数据

devpoint

JavaScript 8月日更 math

neo4j 基本概念与入门实例

escray

学习 neo4j 8月日更

Linux之time命令

入门小站

Linux

解析区块链技术如何帮助企业加速数字转型?

CECBC

ShardingSphere UI 初步体验

源码 ShardingSphere

mycat入门:落地分库分表与读写分离

小鲍侃java

8月日更

【虚拟机专栏】熟悉的新朋友 - 链上JVM

趣链科技

区块链

极光开发者周刊【No.0827】

极光JIGUANG

强强联合!北鲲云与宝德就打造混合云HPC解决方案达成战略合作

北鲲云

GaussDB(for Redis)与原生Redis的性能对比

华为云数据库小助手

redis GaussDB GaussDB ( for Redis ) 华为云数据库

术,路,心:陈天桥的大脑行旅

脑极体

笔记分享 -- 弱网下的极限实时视频通信

声网

音视频 端到端 极限视频通信 弱网

如何使用python制作动感炫酷的 动态二维码

4ye

Python 后端 二维码 8月日更

好身体是吃出来的

石云升

健康 8月日更

命中率高达87%,GitHub收费下载超过28W次的Java面试题库外泄

Java架构师迁哥

03. AI就是与人类思考方式相似的计算机程序:从仿生学看人工智能的定义

数据与智能

人工智能

百度地图开发-绘制点线提示框 07

Andy阿辉

android Android 小菜鸟 Android端 8月日更

Docker 系列 _ 01_ 一念缘起

编程三昧

Docker 8月日更

工业互联网如何加速释放赋能价值?工业智能是关键

浪潮云

云计算

【得物技术】得物App Android Crash治理演进

得物技术

发展 日志 进程 Crash 崩溃

VMware中安装的Ubuntu启动时出现A start job is running for raise network interfaces...

Mike

价值连城 图灵奖得主Yann LeCun 杨立昆的采访 给AI从业者的建议 John 易筋 ARTS 打卡 Week 61

John(易筋)

ARTS 打卡计划

深入理解iOS图文混排原理并自定义图文控件_语言 & 开发_练炽金_InfoQ精选文章