[翻译] Kotlin 与 WebAssembly

原文连接:点此进入,原作者:Federico Tomassetti


本文与以下代码仓库匹配,可以此获取新新的代码。

为什么说 WebAssembly 是意义非凡的?

对于此问题的完整答案,请阅读我们的 WebAssembly 简介: 你为什么需要关注?

简而言之,WebAssembly 可以将非常复杂的应用程序编译为高效的二进制格式,并且可以在 Web 浏览器中以优异的性能运行。

在此之前,我们只有 JavaScript,现在有了用于 Web 的程序集,并且可以将各种语言编译为WebAssembly(也称作 WASM)。 想想 C,C ++,Rust 和 …Kotlin,显然全部可被编译为WASM。

浏览器对 WASM 的支持

在写 71% 浏览器已支持 WASM 的时候,Edge、Firefox、Chrome、Safari 已全部支持 WASM。还在使用 IE 或是怪异的移动浏览器的用户被冷落了,但是所有在本世纪下载了桌面浏览器的用户均已得到了 WASM 的支持。

在某些浏览器中 WASM 已被支持,但在默认情况下禁用。 在最近的 Chrome 和 Firefox 上,WASM 是默认启用的。

是时候用上 WASM了,还是说你依然想玩 Cobol 和Fortran?

Kotlin 对 WASM 支持的现状

我们应该注意的第一件事是 Kotlin 通过其 Kotlin/Native 编译器来支持 WASM。 Kotlin/Native 编译器基于LLVM,并且LLVM支持 WebAssembly,因此我们可以从 Kotlin 源代码获取 WASM 文件。

很好,但这不是我们所需要的。在将 Kotlin 编译为 WASM 时,我们需要更多的东西来提高生产力,并且目前情况非常艰难,我们需要更强大的支持才能完成。但是到目前为止,编译为 WASM 的事情比将 Kotlin 编译为 JVM 或 JavaScript 要困难得多。 你喜欢生活在边缘并展望未来吗? 很酷,但是不要期望一流的服务。

如何通过命令行执行 Kotlin/Native 编译器

在以下两种场景下,你需要从命令行执行 Kotlin/Native 编译器:

  • 当你打算使用 Gradle 和 Konan 插件来编译项目时

  • 当你打算使用 jsinterop(你可以在 伴生仓库 里找到预编译好的文件) 来编译库文件时

无论如何,只要你希望能够直接调用编译器,就需要执行此操作。

首先,你需要下载 Kotlin/Native 的二进制文件。 你可以在 这里 找到它们。

下载二进制文件后,解压缩它们,然后将二进制文件添加到 PATH。 这是很古老是的但是很有用的做法,也许你可以编写自己的小脚本来做到这一点:

#!/usr/bin/env bash
KOTLIN_NATIVE_HOME=/Users/federico/tools/kotlin-native-macos-0.6.2
export PATH=$KOTLIN_NATIVE_HOME/bin:$PATH

现在我们可以看看使用 Gradle 时是如何运作的。

如何使用 Gradle 来编译你的 Kotlin WASM 项目

那很简单,你所需要做的只是在你的 build.gradle 文件贴上这些代码:

buildscript {
    repositories {
        jcenter()
        maven { url "http://kotlin.bintray.com/kotlinx"  }
        maven { url "https://plugins.gradle.org/m2/"  }
        maven { url "https://dl.bintray.com/jetbrains/kotlin-native-dependencies"  }
    }
    dependencies {
        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
        classpath "com.moowork.gradle:gradle-node-plugin:$gradle_node_version"
        classpath "org.jetbrains.kotlin:kotlin-native-gradle-plugin:$kotlin_native_version"
    }
}

apply plugin: 'konan'

konanArtifacts {
    program('stats', targets: ['wasm32'])  {
        srcDir 'src/main/kotlin'
        libraries {
            useRepo 'lib'
            klib 'dom'
        }
    }
}

现在,你只需使用构建目标就可以了。

并且,几乎在所有场景下,你依然需要使用 DOM 库。

DOM 库

当前你可能要与 WASM 一起使用两个库,你可以使用 Kotlin/Native 附带的 jsinterop 工具来构建它们。

尝试一下,这不是真的超级灵活吗?

似乎在 Kotlin 和 WASM 运行时,只支持 两个库,因此你可以构建这两个库,而无需使用 jsinterop 工具。 为了节省时间,我只建立了 DOM 和数学库并将其添加到仓库的 lib 目录中。

好的,现在我们可以真正开始了,我们只需要启动我们的IDE,对吗?但是我有件事必须告诉你...

IDE 的问题

当前仅有一个支持 Kotlin/Native 并同时支持将 Kotlin 应用编译为 WebAssembly 的 IDE,它就是由 Jetbrains 提供的 CLion,并且它并非免费的。

这算是一个问题吗?是的,不过并不像你想象的那么糟。

首先,许多人都是在公司中靠软件赚钱的专业人员,因此购买一些工具不应成为禁忌。不过我知道这对于所有正在学习且无力支付许可证费用的孩子来说都是个问题。 另外还有两件事需要考虑:

1 – 不使用 IDE 是可能的

image

你可以只使用 IntelliJ IDEA 来高亮 Kotlin 语法,但是 IDEA 无法理解 WASM 库,并且不知道如何编译为wasm,因此你必须从命令行来进行编译。 基本上在这个场景下 IntelliJ IDEA 只作为一个平常的编辑器,而不是完整的IDE。

2 – 免费的 IDE 似乎快要来了

image

因此,不要过分强调IDE。 如果您保持内心纯洁,并一直真诚地希望这一点,那么伟大的事情将会发生。 同时,让我们像祖父们一样编程。 或者就像还在使用 vim 的怪异家伙那样。

我们提供的实例

我们提供的实例基于 Jetbrains 在第一届 KotlinConf 上所采用的应用程序。

该应用程序读取一些投票数据,并不断更新图表以显示五个团队之间的投票分布。

它看起来像这样:

好的,来看一下要如何实现它。

文件 src/main/kotlin/main.kt

让我们从 main 函数开始:

import kotlinx.interop.wasm.dom.*
import kotlinx.wasm.jsinterop.*

fun loop(canvas: Canvas) {
    fetch("/stats.json").
            then { args: ArrayList ->
                val response = Response(args[0])
                response.json()
            }.
            then { args: ArrayList ->
                val json = args[0]
                val colors = JsArray(json.getProperty("colors"))
                assert(colors.size == Model.tupleSize)

                val tuple = arrayOf(0, 0, 0, 0, 0)
                for (i in 0 until colors.size) {
                    val color = colors[i].getInt("color")
                    val counter = colors[i].getInt("counter")
                    tuple[color - 1] = counter
                }
                Model.push(tuple)
            }.
            then { View(canvas).render() }
}

fun main(args: Array<String>) {
    val canvas = document.getElementById("myCanvas").asCanvas
    setInterval(100) {
        loop(canvas)
    }
}

main 函数从 DOM 里找到 canvas 元素,然后开启一个无限循环,在这个循环里,每隔 100 毫秒就触发一次循环函数.

循环函数里做了什么呢?

从 stats.json 获取数据
拿到数据把它们塞进 Model
通知视图更新,以显示新的数据

文件 src/main/kotlin/model.kt

现在我们来看看 Model,需要注意,这是一个对象,而不是一个类。 这意味着我们只有 Model 的一个实例,而系统的其余部分(最重要的是 View)可以在不需要引用的情况下访问它。

object Model {
    val tupleSize = 5
    val styles = Style.styles

    val backLogSize = 100
    private val backLog = IntArray(backLogSize * tupleSize, {0})
    private fun offset(time: Int, color: Int) = time * tupleSize + color

    var current = 0
    var maximal = 0

    fun colors(time: Int, color: Int): Int = backLog[offset(time, color)]

    fun tuple(time: Int) = backLog.slice(time * tupleSize .. (time + 1) * tupleSize - 1)

    fun push(new: Array<Int>) {
        assert(new.size == tupleSize)

        new.forEachIndexed { index, it ->
            backLog[offset(current, index)] = it
        }
        current = (current+1) % backLogSize

        new.forEach {
            if (it > maximal) maximal = it
        }
    }
}

这个 model 做了什么呢?

该模型仅接收数据数组(每个“团队”一个值)并将其添加到待办事项列表中。 待办事项列表基本上是 500 个值的数组,换句话说,就是最后 100 个条目,每个条目有 5 个值。

最初,它被设置为仅包含零,但随着时间的流逝,它开始被通过推入接收的实际值填充。

文件 src/main/kotlin/view.kt

太好了,现在我们有了数据,现在是展示该数据的时候了。

在视图文件中,我们有一个对象和两个类:

Style 包含一些关于颜色的常数
Layout 包含有关元素位置,填充,大小等的常量
View 是有趣的事情发生的地方

基本上在View中,我们绘制数据并更新标签。 最外部的标签仅表示团队的 ID:1到5之间的数字。更多的内部标签表示每个团队的最新值。

import kotlinx.interop.wasm.dom.*
import kotlinx.wasm.jsinterop.*

object Style {
    val backgroundColor = "#16103f"
    val teamNumberColor = "#38335b"
    val fontColor = "#000000"
    val styles = arrayOf("#ff7616", "#f72e2e", "#7a6aea", "#4bb8f6", "#ffffff")
}

open class Layout(val rect: DOMRect)  {
    val lowerAxisLegend = 0.1
    val fieldPartHeight = 1.0 - lowerAxisLegend

    val teamNumber = 0.10
    val result = 0.20
    val fieldPartWidth = 1.0 - teamNumber - result

    val teamBackground = 0.05

    val legendPad = 50
    val teamPad = 50
    val resultPad = 40

    val teamRect = 50

    val rectLeft = rect.getInt("left")
    val rectTop = rect.getInt("top")
    val rectRight = rect.getInt("right")
    val rectBottom = rect.getInt("bottom")
    val rectWidth = rectRight - rectLeft
    val rectHeight = rectBottom - rectTop

    val fieldWidth: Int = (rectWidth.toFloat() * fieldPartWidth).toInt()
    val fieldHeight: Int = (rectHeight.toFloat() * fieldPartHeight).toInt()

    val teamWidth = (rectWidth.toFloat() * teamNumber).toInt()
    val teamOffsetX = fieldWidth
    val teamHeight = fieldHeight

    val resultWidth = (rectWidth.toFloat() * result).toInt()
    val resultOffsetX = fieldWidth + teamWidth
    val resultHeight = fieldHeight

    val legendWidth = fieldWidth
    val legendHeight = (rectWidth.toFloat() * lowerAxisLegend)
    val legendOffsetY = fieldHeight
}

class View(canvas: Canvas): Layout(canvas.getBoundingClientRect()) {
    val context = canvas.getContext("2d");

    fun poly(x1: Int, y11: Int, y12: Int, x2: Int, y21: Int, y22: Int, style: String) = with(context) {
        beginPath()
        lineWidth = 2; // In pixels
        setter("strokeStyle", style)
        setter("fillStyle", style)

        moveTo(x1, fieldHeight - y11)
        lineTo(x1, fieldHeight - y12)
        lineTo(x2, fieldHeight - y22)
        lineTo(x2, fieldHeight - y21)
        lineTo(x1, fieldHeight - y11)

        fill()

        closePath()
        stroke()
    }

    fun showValue(index: Int, value: Int, color: String) = with(context) {
        val textCellHeight = teamHeight / Model.tupleSize
        val textBaseline = index * textCellHeight + textCellHeight / 2

        // The team number rectangle
        fillStyle = Style.teamNumberColor
        fillRect(teamOffsetX + teamPad,  teamHeight - textBaseline - teamRect/2, teamRect, teamRect)

        // The team number rectangle
        fillStyle = color
        fillRect(resultOffsetX,  teamHeight - textBaseline - teamRect/2, teamRect/2, teamRect)
    }

    fun showText(index: Int, value: Int, color: String) = with(context) {
        val textCellHeight = teamHeight / Model.tupleSize
        val textBaseline = index * textCellHeight + textCellHeight / 2

        // The team number in the rectangle
        setter("font", "16px monospace")
        setter("textAlign", "center")
        setter("textBaseline", "middle")
        fillStyle = Style.fontColor
        fillText("${index + 1}", teamOffsetX + teamPad + teamRect/2,  teamHeight - textBaseline, teamWidth)

        // The score
        setter("textAlign", "right")
        fillStyle = Style.fontColor
        fillText("$value", resultOffsetX + resultWidth -  resultPad,  resultHeight - textBaseline,  resultWidth)
    }

    fun showLegend() = with(context){
        setter("font", "16px monospace")
        setter("textAlign", "left")
        setter("textBaseline", "top")
        fillStyle = Style.fontColor

        fillText("-10 sec", legendPad, legendOffsetY + legendPad, legendWidth)
        setter("textAlign", "right")
        fillText("now", legendWidth - legendPad, legendOffsetY + legendPad, legendWidth)
    }

    fun scaleX(x: Int): Int {
        return x * fieldWidth / (Model.backLogSize - 2)
    }

    fun scaleY(y: Float): Int {
        return (y * fieldHeight).toInt()
    }

    fun clean() {
        context.fillStyle = Style.backgroundColor
        context.fillRect(0, 0, rectWidth, rectHeight)
    }

    fun render() {
        clean()
        // we take one less, so that there is no jump from the last to zeroth.
        for (t in 0 until Model.backLogSize - 2) {
            val index = (Model.current + t) % (Model.backLogSize - 1)

            val oldTotal = Model.tuple(index).sum()
            val newTotal = Model.tuple(index + 1).sum()

            if (oldTotal == 0 || newTotal == 0) continue // so that we don't divide by zero

            var oldHeight = 0;
            var newHeight = 0;

            for (i in 0 until Model.tupleSize) {
                val style = Model.styles[i]

                val oldValue = Model.colors(index, i)
                val newValue = Model.colors(index+1, i)

                val x1 = scaleX(t)
                val x2 = scaleX(t+1)

                val y11 = scaleY(oldHeight.toFloat() / oldTotal.toFloat())
                val y21 = scaleY(newHeight.toFloat() / newTotal.toFloat())

                val y12 = scaleY((oldHeight + oldValue).toFloat() / oldTotal.toFloat())
                val y22 = scaleY((newHeight + newValue).toFloat() / newTotal.toFloat())

                poly(x1, y11, y12, x2, y21, y22, style);

                oldHeight += oldValue
                newHeight += newValue
            }
        }
        for (i in 0 until Model.tupleSize) {
            val value = Model.colors((Model.current + Model.backLogSize - 1) % Model.backLogSize, i)
            showValue(i, value, Model.styles[i])
        }
        for (i in 0 until Model.tupleSize) {
            val value = Model.colors((Model.current + Model.backLogSize - 1) % Model.backLogSize, i)
            showText(i, value, Model.styles[i])
        }

        showLegend()
    }
}

现在我们有代码了,但是如何使用它?

我们将在下一段中看到这一点。

整合

我们将把 Web 应用程序打包,现在我们需要:

一个得到数据并显示的方法
一个 HTML 页面
包含了编译后代码的 wasm 文件
一个 js 文件用于调用 wasm 代码

为了简便起见,我们将直接从简单的 JSON 文件中获取数据。 当然,在实际的应用程序中,您可能希望数据源更具动态性……

这个就是我们秀出天际的 stats.json:

{
  "colors" : [
    {
      "color": 1,
      "counter": 4
    },
    {
      "color": 2,
      "counter": 14
    },
    {
      "color": 3,
      "counter": 9
    },
    {
      "color": 4,
      "counter": 7
    },
    {
      "color": 5,
      "counter": 6
    }
  ]
}

HTML页面实际上将非常简单,因为它将仅包含画布和加载脚本的代码:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>WASM with Kotlin</title>
    <style>
      html, body {
        width:  100%;
        height: 100%;
        margin: 0px;
      }
    </style>
  </head>
  <body>
    <canvas id="myCanvas">
  </canvas> 
    <script wasm="./stats.wasm" src="./stats.wasm.js"></script>
  </body>
</html>

最后是 stats.wasmstats.wasm.js。 我们通过运行 ./gradlew build 来获得它们。 但这还不够,我们还需要将这些文件放在我们 http 服务的目录中。 因此我们要做的只是将 wasm 和 wasm.js 文件从 build/konan/bin/wasm32/ 复制到 web

现在我们有了所有需要的东西,我们只需要将所有内容发送到浏览器即可。 使用一个非常简单的 http 服务器即可。

使用 simplehttp2server 来提供文件服务

在开发期间,我喜欢使用一个名为 simplehttp2server 的简单解决方案。 你肯定是一个聪明的读者,能够弄清楚如何在平台上安装它或找到有效的替代方法。

举例来说,在 mac 上你可以简单的执行:

brew tap GoogleChrome/simplehttp2server https://github.com/GoogleChrome/simplehttp2server
brew install simplehttp2server

安装完毕后,你要做的就是进入 web 目录并运行 simplehttp2server。 此时的目录应包含 html 文件,wasm 文件,wasm.js 文件和 json 文件。

现在你可以访问 http://localhost:5000 并看到这个应用程序;

好的,什么都没发生,但是如果你打开 stats.json 文件并进行更改,就会看到图像更改。

从 WASM 调用 JavaScript 函数

与 JavaScript 的互操作性是需要改进的。 基本上,编译器生成的包装器向 WASM 文件公开了一些符号。 问题在于,目前显然没有适当的方法向该列表添加更多符号。

假设我们想要添加一个函数以在收到数据后显示警报,我们需要像这样修改 Model 文件:

object Model {
    val tupleSize = 5
    val styles = Style.styles

    val backLogSize = 100
    private val backLog = IntArray(backLogSize * tupleSize, {0})
    private fun offset(time: Int, color: Int) = time * tupleSize + color

    var current = 0
    var maximal = 0

    fun colors(time: Int, color: Int): Int = backLog[offset(time, color)]

    fun tuple(time: Int) = backLog.slice(time * tupleSize .. (time + 1) * tupleSize - 1)

    fun push(new: Array<Int>) {
        igotdata()
        assert(new.size == tupleSize)

        new.forEachIndexed { index, it ->
            backLog[offset(current, index)] = it
        }
        current = (current+1) % backLogSize

        new.forEach {
            if (it > maximal) maximal = it
        }
    }
}

@SymbolName("imported_igotdata")
external public fun igotdata()

好的,但是如何将 JS 函数公开给 Kotlin 代码?

不,目前没有适当的方法,但是你可以使用以下技巧:

在加载程序创建了一些结构后,故意使加载失败
在这些结构中插入一些额外的符号
运行 WASM 文件

对于第一点,我们可以简单地从脚本中删除 wasm 属性:

<script src="./stats.wasm.js"></script>

现在,如果您尝试加载页面,则会出现错误:

现在,让我们插入符号 imported_igotdata 并运行 webassembly:

<script>
        konan.libraries.push({"imported_igotdata":function(msg){ alert("I got the data, updating");}})
        var filename = "./stats.wasm";
        fetch(filename).then( function(response) {
            return response.arrayBuffer();
        }).then(function(arraybuffer) {
            instantiateAndRun(arraybuffer, [filename]);
        });
</script>

建议你在尝试此代码之前将循环从 100ms 减慢到 1000ms。

从 JavaScript 调用 WASM 函数

为此我们可以使用 WebAssembly 对象来编译整个脚本,然后运行它们或运行单个函数。 但这并不是 Kotlin 特有的。

摘要

第一个将 Kotlin 应用程序编译为WASM 为实例

到目前为止还是非常原始的,它显然存在一些问题:

只提供有限的标准库
唯一的 IDE 不是免费的

但是在 Kotlin 的世界中,一切都发展得很快。

我们已经支持跨平台项目。 这意味着我们可以从现在开始构建可在 JVM 和 JavaScript 的世界中使用并编译为 wasm 的库。

一个免费的 IDE 即将面世。 Kotlin/Native 编译器正在快速发展。

事情将很快变得非常有趣,我们建议你做好准备,并开始意识到这个新世界的模样。

相关内容

Kotlin Javascript Target: Use Kotlin in the Browser

Kotlin Mega Tutorial

Kotlin Courses: Reviews And Comparison

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 213,335评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,895评论 3 387
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,766评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,918评论 1 285
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,042评论 6 385
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,169评论 1 291
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,219评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,976评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,393评论 1 304
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,711评论 2 328
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,876评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,562评论 4 336
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,193评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,903评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,142评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,699评论 2 362
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,764评论 2 351

推荐阅读更多精彩内容

  • 转自:https://fed.renren.com/2017/05/21/webassembly/ Webasse...
    core1988阅读 2,692评论 0 1
  • 2018-01-24 20:00作者:刘艳https://mp.weixin.qq.com/s/lFqvdBvus...
    区块链习生阅读 975评论 0 0
  • 在我上课的小区门前,有一条不太长的小路。路边常常摆着卖廉价日用品的小摊,晚上有人卖烧烤。其中一个摊子的主人,养了一...
    F75阅读 1,246评论 8 24
  • 我走在路上,听着《南山南》。 “你在南方的艳阳里大雪纷飞,我在北方的寒夜里四季如春”。每次走在回家的路上,总会听着...
    第77个小队长阅读 354评论 2 1
  • 三毛说: “心若没有栖息的地方,到哪里都是在流浪。”同理,心里如果充满阳光,走到哪里都是一片明亮!心里,若没有快乐...
    姜科宏阅读 1,634评论 15 40