文档章节

Android Text Layout 框架

WolfCS
 WolfCS
发布于 2013/01/31 23:01
字数 2767
阅读 2441
收藏 5

Text Layout,所完成的最主要的功能主要有两点:

  1. 正确的处理换行的逻辑。
  2. 对于那些复杂语系,如阿拉伯语,印度语,希伯来语,缅甸语之类的,依据其语言特性,正确的完成变形,对于由右向左显示的那些语言,正确的完成反序。

在android平台下,其Text Layout框架大体上如下图所显示的这样:

可以看到android 的text layout框架也是牵涉甚广,从Java层,到JNI,到library均有一大坨Code。在Java层,主要完成我们前面提到的第一个功能,即换行的逻辑,其Code主要在StaticLayout这个class中(frameworks/base/core/java/android/text/StaticLayout.java)。这个Class在创建的时候,即会完成整个的换行的处理。它实际上会算出每一行字,在传入的字串中的偏移量,字符的个数,Bidi属性,行的Direction等信息,然后存储在一个数组中,以方便后面在执行绘制等操作的时候使用。

完成换行操作的主要依据有两点,1、Unicode Line Breaking Algorithm有规定说,哪两个字之间可以换行,而哪些字之间最好不要换行;2、依据字的宽度,即每一行在保证能够画得下的情况下,要包含尽可能多的字。在创建StaticLayout的时候,会给它传进去一个参数,outerwidth,做为对于一行的宽度的限制。

StaticLayout切行时的逻辑大致上如下面这样:

1、获取子串中每一个字的宽度,称之为advance或width。
2、初始化如下两组变量,用以记录几种情况下可能可以换行的适当的位置:

248            float w = 0;
249            // here is the offset of the starting character of the line we are currently measuring
250            int here = paraStart;
251
252            // ok is a character offset located after a word separator (space, tab, number...) where
253            // we would prefer to cut the current line. Equals to here when no such break was found.
254            int ok = paraStart;
255            float okWidth = w;
256            int okAscent = 0, okDescent = 0, okTop = 0, okBottom = 0;
257
258            // fit is a character offset such that the [here, fit[ range fits in the allowed width.
259            // We will cut the line there if no ok position is found.
260            int fit = paraStart;
261            float fitWidth = w;
262            int fitAscent = 0, fitDescent = 0, fitTop = 0, fitBottom = 0;
如注释中所描述的,以ok开头的那一组,记录依据Unicode Line Breaking Algorithm,可以进行换行的位置;以fit开头的那一组,则记录依据行的宽度,能容纳得下的最多的字的位置。

3、逐个的遍历记录字的宽度的数组,将字的宽度加到w上,然后比较这个w值和调用者传进来的宽度的限制。当w值小于这个限制的时,会首先去更新以fit开头的那一组数据,更新之后,在去检测当前的字符的位置依据Unicode Line Breaking Algorithm 是否是合适的换行的位置,若是,则同时要去更新ok开头的那一组值 。w值大于行宽度的限制时,则会将当前的这一行的信息输出, 也就是放在存储结果的那个数组里。 当前这一行的信息,会优先采用ok开头的那一组,但如果那一组没有被更新过,则采用fit开头的那一组, 然后重置ok开头及fit开头的那两组值,以开始计算下一行的相关信息。

这个地方我们看到,处理换行时,一个比较重要的依据即是每一个字的宽度。复杂语系的cluster,对于阿拉伯语这类RTL字的处理等,使这个问题变得稍微有些复杂。所谓的cluster,即是对于某些复杂语系,如泰语、印度语、缅甸语等,需要放在一起以执行适当的变形规则的一组字。为了使StaticLayout在处理换行的逻辑时,能始终将一个cluster的字放在相同的行里面,StaticLayout所获取的子串中每一个字的宽度值的数组,将需要具有这样的语义:子串的字宽度数组中对应于cluster首字符的位置,需要放上这个cluster shape之后所产生的所有Glyph的advance之和,而对应于相同cluster中其他字符的位置,则均需要放上0值。相关各个结构的关系大致上如下图这样:


这也是前面的图中所显示的JNI和Shape Engine部分存在的最大价值之所在。

JNI这个部分所做的事情(Shape)大致上如下面这样:

  1. 调用ICU的函数,对传进来的子串做Bidi,将整个字串切分成几个不同方向的子串,每一个子串称为一个 Bidi Run,每一个子串内的字具有相同的方向属性。
  2. 分别处理每一个Bidi Run。对于一个Bidi Run,会首先调用ICU的函数对它做Normalization(依据字串的上下文,将某些字替换为另外的一些字,如越南语中的一些字符,或者依据Bidi属性,比如RTL,将左括号("(")替换为右括号(")")等)。Normalization的逻辑是在Jelly Bean时加入的,因而在ICS上显示越南语时就会产生一些问题。之后将一个Bidi Run切分成几个Script Run。Script Run可以理解为,它们的Glyph的信息一定会同时都包含在或同时都不包含在同一个字库文件中的一组字,通常情况下就是某一个语系的所有的字等。然后,将一个Script Run的文本传递给Shape Engine,来做shape。所谓Shape,即为依据每一个字出现的上下文,来为这个字找到一个适当的Glyph,对字做适当的变形,确定每一个字的适当的位置等。
  3. 依据前面所描述的cluster的逻辑,在通过shape engine对Script run 做shape之后,将advances返回给调用者。返回position信息,返回Glyph ID数组等。

在4.0之后的Android平台,Shape Engine为Harfbuzz。这是一个Open Type shape引擎。即它在对字做变形,确定每一个字的适当的位置的时候,主要利用的是OpenType 字库文件里面的一些如GSUB、GPOS等Table所包含的信息。

可以看到,Shape Engine是需要获取每一个字所对应的GlyphID,并且需要获取到GSUB、GPOS这些table的信息的。许多的Shape Engine在这个地方都做了一些抽象,它们将可以获取到Glyph ID的object称为Font,将可以获取到GSUB、GPOS这些table信息的object称为Face。两者的区别在什么地方呢,最终这些信息不都要从字库文件中来获取吗?仔细考虑,我们会发现,Glyph ID暗含有字体大小,倾斜度等信息在里面,即我们其实要获取的是某一字体大小下的Glyph ID,而GPOS这些table的内容,则实实在在是直接从字库文件里面读取的。

那要如何为Harfbuzz创建Face和Font这些结构呢?直觉上,当然是用字库文件创建了。那要如何使用字库文件呢?传字库文件的path进去,Harfbuzz不就可以想怎么搞就怎么搞了嘛。Harfbuzz等shape engine确实提供有类似于这样的方式来创建Face和Font。但通常情况下,各个系统都会有自己的一套进行Glyph管理,和字库文件管理的系统。由于各个系统,都会有一套自己的Glyph管理系统,一些系统特定的抽象,以便于做cache等以优化性能,因而前面所描述的那种做法所获取的Glyph ID,未必能够和系统的Glyph管理系统实现很好的对接。如果不能对得上的话,那乱码就是必然的了。通常情况下,系统本地都会对字库文件有自己的一套抽象,因而使得对于字库文件的访问,没有办法使用直接传path这类简便的方法。同时借助于系统已有的获取字库文件的table的功能,可以给客户端更大的灵活性来针对系统特性做缓存等,以提升性能、优化memory use等。

在android系统中,是将字库文件抽象为SkTypeface,用SkFontHost来作为字库文件管理系统(Font Manager,此处的Font与前面提到Shape Engine时的那个Font具有不一样的含义,此处指字库文件)。而Glyph管理,则是借助于SkPaint、SkGlyphCache、SkScalerContext等来完成。

那究竟要如何对接shape engine和字库管理系统及Glyph管理系统呢?答案就是callback。通过SkTypeface创建Harfbuzz的Face的code像下面这样:

900HB_Face TextLayoutShaper::getCachedHBFace(SkTypeface* typeface) {
901    SkFontID fontId = typeface->uniqueID();
902    ssize_t index = mCachedHBFaces.indexOfKey(fontId);
903    if (index >= 0) {
904        return mCachedHBFaces.valueAt(index);
905    }
906    HB_Face face = HB_NewFace(typeface, harfbuzzSkiaGetTable);
907    if (face) {
908#if DEBUG_GLYPHS
909        ALOGD("Created HB_NewFace %p from paint typeface = %p", face, typeface);
910#endif
911        mCachedHBFaces.add(fontId, face);
912    }
913    return face;
914}

可以看到主要是将SkTypeface的指针及harfbuzzSkiaGetTable函数指针作为参数,来构造HB_Face。SkTypeface的指针将会被作为Face的user data,在Harfbuzz实际需要读取字库文件中的表的时候,它会被传给harfbuzzSkiaGetTable()函数。而harfbuzz所需要的Font的创建,则主要在TextLayoutShaper的构造函数中:

329TextLayoutShaper::TextLayoutShaper() : mShaperItemGlyphArraySize(0) {
330    init();
331
332    mFontRec.klass = &harfbuzzSkiaClass;
333    mFontRec.userData = 0;
334
335    // Note that the scaling values (x_ and y_ppem, x_ and y_scale) will be set
336    // below, when the paint transform and em unit of the actual shaping font
337    // are known.
338
339    memset(&mShaperItem, 0, sizeof(mShaperItem));
340
341    mShaperItem.font = &mFontRec;
342    mShaperItem.font->userData = &mShapingPaint;
343}
创建FontRec时,所传递的为一组Callback harfbuzzSkiaClass,FontRec的user data为SkPaint的实例mShapingPaint,同前面提到的创建HBFace是的情况类似,harfbuzz 在shape的过程中,需要获取和Glyph有关的信息时,会将SkPaint作为user data传给call back。在Script Run的shape时,会更新 mShapingPaint以适应当前的这次shape。

可以随便拿两个callback是的实现来看看,里面究竟在搞些什么东西,harfbuzzSkiaGetTable()函数

191HB_Error harfbuzzSkiaGetTable(void* font, const HB_Tag tag, HB_Byte* buffer, HB_UInt* len)
192{
193    SkTypeface* typeface = static_cast<SkTypeface*>(font);
194
195    if (!typeface) {
196        ALOGD("Typeface cannot be null");
197        return HB_Err_Invalid_Argument;
198    }
199    const size_t tableSize = SkFontHost::GetTableSize(typeface->uniqueID(), tag);
200    if (!tableSize)
201        return HB_Err_Invalid_Argument;
202    // If Harfbuzz specified a NULL buffer then it's asking for the size of the table.
203    if (!buffer) {
204        *len = tableSize;
205        return HB_Err_Ok;
206    }
207
208    if (*len < tableSize)
209        return HB_Err_Invalid_Argument;
210    SkFontHost::GetTableData(typeface->uniqueID(), tag, 0, tableSize, buffer);
211    return HB_Err_Ok;
212}
harfbuzzSkiaClass 的convertStringToGlyphIndices callback stringToGlyphs()函数:

50static HB_Bool stringToGlyphs(HB_Font hbFont, const HB_UChar16* characters, hb_uint32 length,
51        HB_Glyph* glyphs, hb_uint32* glyphsSize, HB_Bool isRTL)
52{
53    SkPaint* paint = static_cast<SkPaint*>(hbFont->userData);
54    paint->setTextEncoding(SkPaint::kUTF16_TextEncoding);
55
56    uint16_t* skiaGlyphs = reinterpret_cast<uint16_t*>(glyphs);
57    int numGlyphs = paint->textToGlyphs(characters, length * sizeof(uint16_t), skiaGlyphs);
58
59    // HB_Glyph is 32-bit, but Skia outputs only 16-bit numbers. So our
60    // |glyphs| array needs to be converted.
61    for (int i = numGlyphs - 1; i >= 0; --i) {
62        glyphs[i] = skiaGlyphs[i];
63    }
64
65    *glyphsSize = numGlyphs;
66    return 1;
67}
可以看到,是强制类型转换的巧妙的应用,使得Harfbuzz和Skia完成了对接。

© 著作权归作者所有

WolfCS
粉丝 81
博文 147
码字总数 505184
作品 4
杭州
高级程序员
私信 提问
加载中

评论(14)

代码GG
代码GG

引用来自“ameihualudeai1”的评论

请问下 如泰语、印度语、缅甸语等,需要放在一起以执行适当的变形规则的一组字 ,这种要加一种类似的语言,又没什么文档可以参照呢?
谢谢

引用来自“WolfCS”的评论

5.1.1的系统下http://androidxref.com/5.1.1_r6/xref/external/noto-fonts/,可以看到也有了缅甸语的字库文件了NotoSansMyanmar-*这几个。
谢谢你的回复。我的目标是要加一组跟缅甸语类似的变形字语言,不知该如何下手。
代码GG
代码GG

引用来自“ameihualudeai1”的评论

请问下 如泰语、印度语、缅甸语等,需要放在一起以执行适当的变形规则的一组字 ,这种要加一种类似的语言,又没什么文档可以参照呢?
谢谢

引用来自“WolfCS”的评论

泰语和印度语,是比较早的时候,android就默认支持的,而且到现在都已经支持的相当好了。 缅甸语的话,基本上加个字库文件,修改一下配置字库文件的配置文件就可以了吧。 如果是要让某个app支持这些语言的话,那估计就更容易了吧,在app中创建Typeface,然后set给Button或这TextView这样的一些组件就可以了啊。
感谢楼主的耐心回答。我的意思是我现在要去加一组跟缅甸语类似的,也是会有变型字的语言,我该怎么加。icu那些xml里面的字段是在哪里找到的呢?变型字处理是在harbuzz-ng里面做的吗?谢谢了。
WolfCS
WolfCS 博主

引用来自“ameihualudeai1”的评论

请问下 如泰语、印度语、缅甸语等,需要放在一起以执行适当的变形规则的一组字 ,这种要加一种类似的语言,又没什么文档可以参照呢?
谢谢
5.1.1的系统下http://androidxref.com/5.1.1_r6/xref/external/noto-fonts/,可以看到也有了缅甸语的字库文件了NotoSansMyanmar-*这几个。
WolfCS
WolfCS 博主

引用来自“ameihualudeai1”的评论

请问下 如泰语、印度语、缅甸语等,需要放在一起以执行适当的变形规则的一组字 ,这种要加一种类似的语言,又没什么文档可以参照呢?
谢谢
泰语和印度语,是比较早的时候,android就默认支持的,而且到现在都已经支持的相当好了。 缅甸语的话,基本上加个字库文件,修改一下配置字库文件的配置文件就可以了吧。 如果是要让某个app支持这些语言的话,那估计就更容易了吧,在app中创建Typeface,然后set给Button或这TextView这样的一些组件就可以了啊。
代码GG
代码GG
请问下 如泰语、印度语、缅甸语等,需要放在一起以执行适当的变形规则的一组字 ,这种要加一种类似的语言,又没什么文档可以参照呢?
谢谢
l
lil

引用来自“WolfCS”的评论

引用来自“lil”的评论

引用来自“WolfCS”的评论

引用来自“lil”的评论

抱歉,描述不详细。
是指字与字之间的间距。
我试过测量字宽后,逐个字画来实现字间距,但是性能相比一次画完降低了很多。
不知TextLayout有没有办法在不拆开字画的同时,实现字间距调整,和换行呢?

不知道您这个需求的来源是什么,也不知道这种功能的应用场景是什么。通常情况下,字与字之间的宽度,都是由字体设计者设计好,存放在字库文件中的。如果可以修改frameworks jni下的code的话,感觉应该可以修改下面的这段代码(frameworks/base/core/jni/android/graphics/TextLayoutCache.cpp,android4.2.2)来实现您的需求:
854 assert(mShaperItem.item.length > 0); // Harfbuzz will overwrite other memory if length is 0.
855 size_t size = mShaperItem.item.length * 3 / 2;
856 while (!doShaping(size)) {
857 // We overflowed our glyph arrays. Resize and retry.
858 // HB_ShapeItem fills in shaperItem.num_glyphs with the needed size.
859 size = mShaperItem.num_glyphs * 2;
860 }
861 return baseGlyphCount;

就是在那个while循环的后面,加一段code,把mShaperItem这个成员变量,它的advances成员数组中的每一个成员都加上或者减去一定的数量,在成员值为0时,不要修改该成员。

改变源码就不现实了,我的情况是,阅读器需要对字体排版,比如说,给一个最大宽度,然后把一些小于这个宽度的文字均匀分布地画上去。就像 miui的多看阅读器那种效果,我目前是计算完每个字宽后,一个个字画上去

android的Paint类有一组方法,getTextWidths(),可以获取到一个字串中,每一个字的宽度,感觉您应该可以获取字串中每一个字的宽度,然后再对每个字的宽度做您需要的处理。
完了之后,根据字宽度,计算出每一个字的位置,再调用Canvas.drawPosText()来画字。
这种做法应该可以处理非复杂语系的情况。

谢谢你的指导,帮大忙了。之前看到drawPosText方法在android4.0后版本被弃用了,所以没考虑。
不过测试了一下,就算这样画,也是很耗性能,而且还要画一个屏幕大小的背景图(平铺模式的drawable有概率崩),每帧耗时约20MS,还是有不流畅感觉。
现在我是,先把内容画到一个页面大小的图片上,用的时候,只把图片绘制到界面上
WolfCS
WolfCS 博主

引用来自“lil”的评论

引用来自“WolfCS”的评论

引用来自“lil”的评论

抱歉,描述不详细。
是指字与字之间的间距。
我试过测量字宽后,逐个字画来实现字间距,但是性能相比一次画完降低了很多。
不知TextLayout有没有办法在不拆开字画的同时,实现字间距调整,和换行呢?

不知道您这个需求的来源是什么,也不知道这种功能的应用场景是什么。通常情况下,字与字之间的宽度,都是由字体设计者设计好,存放在字库文件中的。如果可以修改frameworks jni下的code的话,感觉应该可以修改下面的这段代码(frameworks/base/core/jni/android/graphics/TextLayoutCache.cpp,android4.2.2)来实现您的需求:
854 assert(mShaperItem.item.length > 0); // Harfbuzz will overwrite other memory if length is 0.
855 size_t size = mShaperItem.item.length * 3 / 2;
856 while (!doShaping(size)) {
857 // We overflowed our glyph arrays. Resize and retry.
858 // HB_ShapeItem fills in shaperItem.num_glyphs with the needed size.
859 size = mShaperItem.num_glyphs * 2;
860 }
861 return baseGlyphCount;

就是在那个while循环的后面,加一段code,把mShaperItem这个成员变量,它的advances成员数组中的每一个成员都加上或者减去一定的数量,在成员值为0时,不要修改该成员。

改变源码就不现实了,我的情况是,阅读器需要对字体排版,比如说,给一个最大宽度,然后把一些小于这个宽度的文字均匀分布地画上去。就像 miui的多看阅读器那种效果,我目前是计算完每个字宽后,一个个字画上去

android的Paint类有一组方法,getTextWidths(),可以获取到一个字串中,每一个字的宽度,感觉您应该可以获取字串中每一个字的宽度,然后再对每个字的宽度做您需要的处理。
完了之后,根据字宽度,计算出每一个字的位置,再调用Canvas.drawPosText()来画字。
这种做法应该可以处理非复杂语系的情况。
l
lil

引用来自“WolfCS”的评论

引用来自“lil”的评论

抱歉,描述不详细。
是指字与字之间的间距。
我试过测量字宽后,逐个字画来实现字间距,但是性能相比一次画完降低了很多。
不知TextLayout有没有办法在不拆开字画的同时,实现字间距调整,和换行呢?

不知道您这个需求的来源是什么,也不知道这种功能的应用场景是什么。通常情况下,字与字之间的宽度,都是由字体设计者设计好,存放在字库文件中的。如果可以修改frameworks jni下的code的话,感觉应该可以修改下面的这段代码(frameworks/base/core/jni/android/graphics/TextLayoutCache.cpp,android4.2.2)来实现您的需求:
854 assert(mShaperItem.item.length > 0); // Harfbuzz will overwrite other memory if length is 0.
855 size_t size = mShaperItem.item.length * 3 / 2;
856 while (!doShaping(size)) {
857 // We overflowed our glyph arrays. Resize and retry.
858 // HB_ShapeItem fills in shaperItem.num_glyphs with the needed size.
859 size = mShaperItem.num_glyphs * 2;
860 }
861 return baseGlyphCount;

就是在那个while循环的后面,加一段code,把mShaperItem这个成员变量,它的advances成员数组中的每一个成员都加上或者减去一定的数量,在成员值为0时,不要修改该成员。

改变源码就不现实了,我的情况是,阅读器需要对字体排版,比如说,给一个最大宽度,然后把一些小于这个宽度的文字均匀分布地画上去。就像 miui的多看阅读器那种效果,我目前是计算完每个字宽后,一个个字画上去
WolfCS
WolfCS 博主

引用来自“lil”的评论

抱歉,描述不详细。
是指字与字之间的间距。
我试过测量字宽后,逐个字画来实现字间距,但是性能相比一次画完降低了很多。
不知TextLayout有没有办法在不拆开字画的同时,实现字间距调整,和换行呢?

不知道您这个需求的来源是什么,也不知道这种功能的应用场景是什么。通常情况下,字与字之间的宽度,都是由字体设计者设计好,存放在字库文件中的。如果可以修改frameworks jni下的code的话,感觉应该可以修改下面的这段代码(frameworks/base/core/jni/android/graphics/TextLayoutCache.cpp,android4.2.2)来实现您的需求:
854 assert(mShaperItem.item.length > 0); // Harfbuzz will overwrite other memory if length is 0.
855 size_t size = mShaperItem.item.length * 3 / 2;
856 while (!doShaping(size)) {
857 // We overflowed our glyph arrays. Resize and retry.
858 // HB_ShapeItem fills in shaperItem.num_glyphs with the needed size.
859 size = mShaperItem.num_glyphs * 2;
860 }
861 return baseGlyphCount;

就是在那个while循环的后面,加一段code,把mShaperItem这个成员变量,它的advances成员数组中的每一个成员都加上或者减去一定的数量,在成员值为0时,不要修改该成员。
l
lil
抱歉,描述不详细。
是指字与字之间的间距。
我试过测量字宽后,逐个字画来实现字间距,但是性能相比一次画完降低了很多。
不知TextLayout有没有办法在不拆开字画的同时,实现字间距调整,和换行呢?
浅谈android4.0开发之GridLayout布局

本文重点讲述了自android4.0版本后新增的GridLayout网格布局的一些基本内容,并在此基础上实现了一个简单的计算器布局框架。通过本文,您可以了解到一些android UI开发的新特性,并能够实现相...

mutouzhang
2014/03/28
217
1
针对RelativLayout的alignWithParentIfMissing属性问题

针对RelativeLayout布局,有一点问题,需要明确,因为它内部是多个view间的关系确定的框架,那么当其中的一个view因其它view隐藏后,会影响其相关的views,所以安卓提供一属性解决此问题ali...

ZHXIA
2015/01/01
1K
0
Android基础教程之五大布局对象------FrameLayout,LinearLayout,AbsoluteLayout,RelativeLayout,TableLayout

大家好,我们这一节讲一下Android对用五大布局对象,它们分别是FrameLayout(框架布局:不知道是不是这么翻译的),LinearLayout (线性布局),AbsoluteLayout(绝对布局),RelativeLayout(相对布局),T...

神勇小白鼠
2011/01/05
3.2K
0
Android布局

1.什么是布局 一个Android应用的用户界面是由View和ViewGroup构建的,他们有很多的种类,并且都是View的子类,View类的一些子类被称为“widgets(工具)”,他们提供了诸如文本输入框和按钮之类...

晨曦之光
2012/05/16
424
0
android设置layout的时候为什么大小颠倒了啊

我最后两个layout明明设置的是3:2但是到手机上显示的时候成了2:3了,求教。下面是我的布局文件:

找到组织
2013/01/25
281
0

没有更多内容

加载失败,请刷新页面

加载更多

排序––快速排序(二)

根据排序––快速排序(一)的描述,现准备写一个快速排序的主体框架: 1、首先需要设置一个枢轴元素即setPivot(int i); 2、然后需要与枢轴元素进行比较即int comparePivot(int j); 3、最后...

FAT_mt
昨天
4
0
mysql概览

学习知识,首先要有一个总体的认识。以下为mysql概览 1-架构图 2-Detail csdn |简书 | 头条 | SegmentFault 思否 | 掘金 | 开源中国 |

程序员深夜写bug
昨天
10
0
golang微服务框架go-micro 入门笔记2.2 micro工具之微应用利器micro web

micro web micro 功能非常强大,本文将详细阐述micro web 命令行的功能 阅读本文前你可能需要进行如下知识储备 golang分布式微服务框架go-micro 入门笔记1:搭建go-micro环境, golang微服务框架...

非正式解决方案
昨天
6
0
前端——使用base64编码在页面嵌入图片

因为页面中插入一个图片都要写明图片的路径——相对路径或者绝对路径。而除了具体的网站图片的图片地址,如果是在自己电脑文件夹里的图片,当我们的HTML文件在别人电脑上打开的时候图片则由于...

被毒打的程序猿
昨天
9
0
Flutter 系列之Dart语言概述

Dart语言与其他语言究竟有什么不同呢?在已有的编程语言经验的基础上,我们该如何快速上手呢?本篇文章从编程语言中最重要的组成部分,也就是基础语法与类型变量出发,一起来学习Dart吧 一、...

過愙
昨天
6
0

没有更多内容

加载失败,请刷新页面

加载更多

返回顶部
顶部