当前位置: 首页 > news >正文

使用Kotlin进行全栈开发 Ktor+Kotlin/JS

首发于Enaium的个人博客


前言

本文将介绍如何使用 Kotlin 全栈技术栈Ktor+Kotlin/JS来构建一个简单的全栈应用。

准备工作

创建项目

首先我们需要创建一个Kotlin项目,之后继续在其中新建两个子项目,一个是Kotlin/JS项目,另一个是Ktor项目。

添加依赖和插件

这里我使用了Gradlecatalog,在项目中的gradle目录下创建一个libs.versions.toml文件,用于管理项目中的依赖版本。

[versions]
jimmer = "0.0.9"
kotlin = "1.9.23"
ktor = "2.3.9"
ksp = "1.9.23-1.0.20"
coroutines = "1.8.0"
serialization = "1.6.3"
wrappers = "1.0.0-pre.729"
logback = "1.5.3"
postgresql = "42.7.3"
hikari = "5.1.0"
koin = "3.5.6"[libraries]
ktor-server-core = { module = "io.ktor:ktor-server-core-jvm", version.ref = "ktor" }
ktor-server-netty = { module = "io.ktor:ktor-server-netty-jvm", version.ref = "ktor" }
ktor-server-cors = { module = "io.ktor:ktor-server-cors", version.ref = "ktor" }
ktor-server-content-negotiation = { module = "io.ktor:ktor-server-content-negotiation", version.ref = "ktor" }
ktor-serialization-jsackson = { module = "io.ktor:ktor-serialization-jackson", version.ref = "ktor" }
ktor-server-config-yaml = { module = "io.ktor:ktor-server-config-yaml", version.ref = "ktor" }
logback = { module = "ch.qos.logback:logback-classic", version.ref = "logback" }
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" }
kotlin-wrappers = { module = "org.jetbrains.kotlin-wrappers:kotlin-wrappers-bom", version.ref = "wrappers" }
kotlin-wrappers-react = { module = "org.jetbrains.kotlin-wrappers:kotlin-react" }
kotlin-wrappers-react-dom = { module = "org.jetbrains.kotlin-wrappers:kotlin-react-dom" }
kotlin-wrappers-emotion = { module = "org.jetbrains.kotlin-wrappers:kotlin-emotion" }
postgresql = { module = "org.postgresql:postgresql", version.ref = "postgresql" }
hikari = { module = "com.zaxxer:HikariCP", version.ref = "hikari" }
koin = { module = "io.insert-koin:koin-ktor", version.ref = "koin" }[bundles]
api = ['ktor-server-core', 'ktor-server-netty', 'ktor-server-cors', 'ktor-server-content-negotiation', 'ktor-serialization-jsackson', 'ktor-server-config-yaml', 'logback', 'postgresql', 'hikari', 'koin']
app = ['kotlinx-coroutines-core', 'kotlinx-serialization-json', 'kotlin-wrappers-react', 'kotlin-wrappers-react-dom', 'kotlin-wrappers-emotion'][plugins]
jimmer = { id = "cn.enaium.jimmer.gradle", version.ref = "jimmer" }
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
ktor = { id = "io.ktor.plugin", version.ref = "ktor" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
kotlin-plugin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }

之后我们分别在前端和后端项目中的build.gradle.kts文件中引入这些依赖和插件。

后端
plugins {alias(libs.plugins.kotlin.jvm)alias(libs.plugins.ktor)alias(libs.plugins.ksp)alias(libs.plugins.jimmer)application
}group = "cn.enaium"
version = "1.0.0"application {mainClass = "cn.enaium.TodoKt"applicationDefaultJvmArgs = listOf("-Dio.ktor.development=${extra["development"] ?: "false"}")
}dependencies {implementation(libs.bundles.api)
}

这里有一个配置,添加到gradle.properties文件中。

development=true
前端
plugins {alias(libs.plugins.kotlin.multiplatform)alias(libs.plugins.kotlin.plugin.serialization)
}kotlin {js {browser {commonWebpackConfig {cssSupport {enabled.set(true)}}}binaries.executable()}sourceSets {val jsMain by getting {dependencies {implementation(project.dependencies.enforcedPlatform(libs.kotlin.wrappers))implementation(libs.bundles.app)}}}
}

这里需要将前端项目的src/main改为src/jsMain

最后进入到根项目的settings.gradle.kts文件中添加以下代码。

pluginManagement {repositories {maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")google()gradlePluginPortal()mavenCentral()}
}dependencyResolutionManagement {repositories {google()mavenCentral()maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")}
}

还有gradle.build.kts文件中只保留以下代码。

plugins {alias(libs.plugins.kotlin.jvm) apply falsealias(libs.plugins.kotlin.multiplatform) apply false
}

好了,现在我们的项目已经准备好了。

编写代码

后端

首先创建配置文件src/main/resources/application.yml

ktor:deployment:port: 8080application:modules:- cn.enaium.TodoKt.module
jdbc:driver: 'org.postgresql.Driver'url: 'jdbc:postgresql://localhost:5432/postgres?currentSchema=todo'username: 'postgres'password: 'postgres'

之后创建logback配置文件src/main/resources/logback.xml

<configuration><appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender"><encoder><pattern>%d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern></encoder></appender><root level="trace"><appender-ref ref="STDOUT"/></root><logger name="org.eclipse.jetty" level="INFO"/><logger name="io.netty" level="INFO"/>
</configuration>

还有创建数据库。

drop schema if exists todo cascade;
create schema todo;drop table if exists todo.task;
create table todo.task
(id         uuid primary key,name       text not null,start_time timestamp default now(),end_time   timestamp
)

之后创建一个主类cn.enaium.Todo

fun main(args: Array<String>) = EngineMain.main(args)

之后编写一个扩展函数cn.enaium.Todo.module

fun Application.module() {}

安装一些插件

Koin
install(Koin) {modules(module {single<ApplicationEnvironment> { environment }})
}
CORS
install(CORS) {allowMethod(HttpMethod.Options)allowMethod(HttpMethod.Post)allowMethod(HttpMethod.Get)allowHeader(HttpHeaders.AccessControlAllowOrigin)allowHeader(HttpHeaders.ContentType)anyHost()
}
Jackson
install(ContentNegotiation) {jackson {registerModules(ImmutableModule())}
}
Jimmer

接下来配置一下Jimmer

fun sql(environment: ApplicationEnvironment): KSqlClient {return newKSqlClient {setConnectionManager {HikariPool(HikariConfig().apply {driverClassName = environment.config.property("jdbc.driver").getString()jdbcUrl = environment.config.property("jdbc.url").getString()username = environment.config.property("jdbc.username").getString()password = environment.config.property("jdbc.password").getString()maximumPoolSize = 10connectionTimeout = 30000}).connection.use {proceed(it)}}setDialect(PostgresDialect())}
}

之后添加到Koin中。

single<KSqlClient> { sql(get()) }

编写一个Task实体类。

package cn.enaium.entityimport org.babyfish.jimmer.sql.Entity
import org.babyfish.jimmer.sql.GeneratedValue
import org.babyfish.jimmer.sql.Id
import org.babyfish.jimmer.sql.Table
import org.babyfish.jimmer.sql.meta.UUIDIdGenerator
import java.util.*/*** @author Enaium*/
@Entity
@Table(name = "task")
interface Task {@Id@GeneratedValue(generatorType = UUIDIdGenerator::class)val id: UUIDval name: Stringval startTime: Dateval endTime: Date?
}

接下来就可以编写Service了。

package cn.enaium.serviceimport cn.enaium.entity.Task
import cn.enaium.entity.endTime
import cn.enaium.entity.startTime
import org.babyfish.jimmer.sql.kt.KSqlClient
import org.babyfish.jimmer.sql.kt.ast.expression.isNotNull/*** @author Enaium*/
class TodoServe(private val sql: KSqlClient) {fun getTasks(): List<Task> {return sql.createQuery(Task::class) {orderBy(table.endTime.isNotNull(), table.startTime)select(table)}.execute()}fun saveTask(task: Task) {sql.save(task)}
}

这里我们添加两个方法getTaskssaveTaskgetTasks用于获取所有任务并按照创建时间和是否完成排序,saveTask用于保存任务,之后还是添加到Koin中。

single<TodoServe> { TodoServe(get()) }

之后我们在module添加路由。

val todoServe by inject<TodoServe>()routing {get("/task") {call.respond(todoServe.getTasks())}post("/task") {todoServe.saveTask(call.receive())call.response.status(HttpStatusCode.OK)}
}

前端

首先在src/jsMain/resources/index.html中添加以下代码,这里需要注意的是app.js,这个文件名称需要和前端的项目名称一致。

<!doctype html>
<html lang="en">
<head><meta charset="UTF-8"><title>Hello, Kotlin/JS!</title>
</head>
<body>
<div id="root"></div>
<script src="app.js"></script>
</body>
</html>

之后写一个main函数。

import react.dom.client.createRoot
import web.dom.document/*** @author Enaium*/
fun main() {val container = document.getElementById("root") ?: error("Couldn't find root container!")createRoot(container).render(App.create())
}val App = FC {}

然后就可以编写组件了。

首先需要创建两个data类,一个是Task,另一个是TaskInputTask用于展示任务,TaskInput用于请求。

@Serializable
data class Task(val id: String, var name: String, val startTime: Long, val endTime: Long?) {fun copy(name: String = this.name, startTime: Long = this.startTime, endTime: Long? = this.endTime) =Task(id, name, startTime, endTime)fun toInput() = TaskInput(id, name, startTime, endTime)
}@Serializable
data class TaskInput(val id: String? = null,val name: String? = null,val startTime: Long? = null,val endTime: Long? = null
)

之后编写请求函数,使用fetch发送请求。

val coroutine = CoroutineScope(window.asCoroutineDispatcher())suspend fun fetchTasks(): List<Task> {window.fetch("http://localhost:8080/task").await().let {if (it.status != 200.toShort()) {throw Exception("Failed to fetch")}return Json.decodeFromDynamic<List<Task>>(it.json().await())}
}suspend fun saveTask(task: TaskInput) {window.fetch("http://localhost:8080/task",RequestInit(method = "POST",body = Json.encodeToString(TaskInput.serializer(), task),headers = json("Content-Type" to "application/json"))).await().let {if (it.status != 200.toShort()) {throw Exception("Failed to save")}}
}
TaskItem

编写一个TaskItem组件,用于展示任务,编辑任务,完成任务,逻辑就是点击Edit按钮可以编辑任务,按Enter保存,按Escape取消,点击Finish按钮完成任务。

external interface TaskItemProps : Props {var task: Task
}val TaskItem = FC<TaskItemProps> { props ->var editState by useState(false)var taskState by useState<TaskInput>()useEffect(listOf(taskState)) {taskState?.let {coroutine.launch {saveTask(it)window.location.reload()}}}div {if (editState) {input {defaultValue = props.task.nameonKeyUp = {if (it.asDynamic().key == "Enter") {taskState = props.task.copy(name = it.target.asDynamic().value as String).toInput()editState = false}if (it.asDynamic().key == "Escape") {editState = false}}}} else {div {css {color = if (props.task.endTime == null) Color("red") else Color("green")}div {+props.task.id}div {+props.task.name}div {+kotlin.js.Date(props.task.startTime).toLocaleString()props.task.endTime?.let {+" - "+kotlin.js.Date(it).toLocaleString()}}}button {+"Edit"onClick = {editState = !editState}}button {+"Finish"onClick = {taskState = props.task.copy(endTime = Date().getTime().toLong()).toInput()}}}}
}
App

最后编写App组件,获取任务列表,添加任务。

val App = FC {var tasksState by useState(emptyList<Task>())var taskState by useState<TaskInput>()useEffectOnce {coroutine.launch {tasksState = fetchTasks()}}useEffect(listOf(taskState)) {taskState?.let {coroutine.launch {saveTask(it)window.location.reload()}}}div {input {css {fontSize = 24.px}onKeyUp = {if (it.asDynamic().key == "Enter") {taskState = TaskInput(name = it.target.asDynamic().value as String)}}}div {css {marginTop = 10.pxdisplay = Display.flexflexDirection = FlexDirection.columngap = 10.px}tasksState.forEach {TaskItem {key = it.idtask = it}}}}
}

运行

前端和后端默认端口都是8080,所以先运行后端,之后运行前端。

后端使用application插件的run任务,前端使用jsBrowserDevelopmentRun任务。

相关文章:

使用Kotlin进行全栈开发 Ktor+Kotlin/JS

首发于Enaium的个人博客 前言 本文将介绍如何使用 Kotlin 全栈技术栈KtorKotlin/JS来构建一个简单的全栈应用。 准备工作 创建项目 首先我们需要创建一个Kotlin项目&#xff0c;之后继续在其中新建两个子项目&#xff0c;一个是Kotlin/JS项目&#xff0c;另一个是Ktor项目。…...

数据结构_带头双向循环链表

List.h 相较于之前的顺序表和单向链表&#xff0c;双向链表的逻辑结构稍微复杂一些&#xff0c;但是在实现各种接口的时候是很简单的。因为不用找尾&#xff0c;写起来会舒服一点。&#xff08;也可能是因为最近一直在写这个的原因&#xff09; #pragma once #include<std…...

常见的垃圾回收器(下)

文章目录 G1ShenandoahZGC 常见垃圾回收期&#xff08;上&#xff09; G1 参数1&#xff1a; -XX:UseG1GC 打开G1的开关&#xff0c;JDK9之后默认不需要打开 参数2&#xff1a;-XX:MaxGCPauseMillis毫秒值 最大暂停的时间 回收年代和算法 ● 年轻代老年代 ● 复制算法 优点…...

网桥的原理

网桥的原理 1.1 桥接的概念 简单来说&#xff0c;桥接就是把一台机器上的若干个网络接口“连接”起来&#xff0c;其结果是&#xff0c;其中一个网口收到的报文会被复制给其他网口并发送出去。以使得网口之间的报文能够互相转发。 交换机有若干个网口&#xff0c;并且这些…...

STM32 CAN过滤器细节

STM32 CAN过滤器细节 简介 每组筛选器包含2个32位的寄存器&#xff0c;分别为CAN_FxR1和CAN_FxR2&#xff0c;它们用来存储要筛选的ID或掩码 四种模式 模式说明32位掩码模式CAN_FxR1存储ID&#xff0c; CAN_FxR2存储哪个位必须要与CAN_FxR1中的ID一致 &#xff0c; 2个寄存器…...

网络编程(现在不重要)

目录 网络编程三要素与InetAddress类的使用 软件架构 面临的主要问题 网络编程三要素&#xff08;对应三个问题&#xff09; InetAddress的使用 TCP与UDP协议剖析与TCP编程案例&#xff08;了解&#xff09; TCP协议 UDP协议 例子 UDP、URL网络编程 URL&#xff1a;&…...

10-菜刀连接木马

找到了漏洞后&#xff0c;并且上传了木马之后才能使用的两款工具 中国菜刀和冰蝎 想办法获取别人的cookie&#xff0c;cookie中有session-id 一、中国菜刀 1、必须提前已经完成木马植入然后才能使用 2、木马必须是POST请求&#xff0c;参数自定义&#xff0c;在菜刀里给出…...

Unity数据持久化—Json存档

项目需求为&#xff1a; 1.实现存档列表&#xff0c;显示存档截图&#xff0c;可以查看之前保存的所有存档 2.点击存档直接加载到场景 首先&#xff0c;定义两个类&#xff0c;用于声明存档列表和存档所需要的List [System.Serializable] public class SaveData {//存储目标…...

基于SSM的在线学习系统的设计与实现(论文+源码)_kaic

基于SSM的在线学习系统的设计与实现 摘要 随着信息互联网购物的飞速发展&#xff0c;一般企业都去创建属于自己的管理系统。本文介绍了在线学习系统的开发全过程。通过分析企业对于在线学习系统的需求&#xff0c;创建了一个计算机管理在线学习系统的方案。文章介绍了在线学习系…...

数据库SQL语言实战(二)

目录 检索查询 题目一 题目二 题目三 题目四 题目五 题目六 题目七 题目八 题目九&#xff08;本篇最难的题目&#xff09; 分析 实现&#xff08;两种方式&#xff09; 模板 总结 检索查询 按照要求查找数据库中的数据 题目一 找出没有选修任何课程的学…...

idea错误地commit后如何处理

如果你想使用命令行重新初始化 Git 仓库&#xff0c;可以按照以下步骤进行&#xff1a; 删除该项目的.git文件夹 打开命令行终端。 切换到项目所在的目录&#xff0c;使用 cd 命令。 在项目目录下运行以下命令来重新初始化 Git 仓库 git init这将在当前目录下创建一个新的 Git …...

VRTK(Virtual Reality Toolkit)深入介绍

VRTK是一个为Unity引擎设计的开源虚拟现实&#xff08;VR&#xff09;开发框架&#xff0c;旨在简化和加速VR应用的开发过程。这个工具包包含了一系列的模块和预设&#xff0c;使得开发者可以快速集成标准的VR功能&#xff0c;如物体交互、环境导航、用户界面管理等。下面将对V…...

【LeetCode热题100】【贪心算法】划分字母区间

题目链接&#xff1a;763. 划分字母区间 - 力扣&#xff08;LeetCode&#xff09; 要将一个字符串划分为多个子串&#xff0c;要求每个字母只能出现在一个子串里面 如果一个字母的当前位置是它在这个字符串里面最后一次出现的位置&#xff0c;那么这里就应该划分出来成为子串…...

第二届数据安全大赛暨首届“数信杯”数据安全大赛数据安全积分争夺赛-东区预赛部分WP

这里写目录标题 检材下载&#xff1a;1.理论题2.数据安全&#xff1a;pb:Sepack&#xff1a; 3.数据分析&#xff1a;数据分析&#xff08;1&#xff09;数据分析1-1:数据分析1-2:数据分析1-3: 数据分析&#xff08;3&#xff09;数据分析3-1&#xff1a;数据分析3-2&#xff1…...

如何在Python中使用matplotlib库进行数据可视化?

如何在Python中使用matplotlib库进行数据可视化&#xff1f; 在Python中使用matplotlib库进行数据可视化 数据可视化是将数据以图形或图像的形式展示出来的过程&#xff0c;它有助于我们更好地理解和分析数据。在Python中&#xff0c;matplotlib是一个非常受欢迎的数据可视化…...

网工基础协议——TCP/UDP协议

TCP和UDP的不同点&#xff1a; TCP(Transmission Control Protocol&#xff0c;传输控制协议)&#xff1b; UDP(User Data Protocol&#xff0c;用户数据报协议)&#xff1b; TCP&#xff1a;传输控制协议&#xff0c;面向连接可靠的协议&#xff0c;只能适用于单播通信&…...

ClickHouse--16--普通函数

提示&#xff1a;文章写完后&#xff0c;目录可以自动生成&#xff0c;如何生成可参考右边的帮助文档 文章目录 一、日期函数1、时间或日期截取函数&#xff08;返回非日期&#xff09;2、时间或日期截取函数&#xff08;返回日期&#xff09;3、日期或时间日期生成函数 二、类…...

03-JAVA设计模式-组合模式

组合模式 什么是组合模式 组合模式&#xff08;Composite Pattern&#xff09;允许你将对象组合成树形结构以表示“部分-整体”的层次结构&#xff0c;使得客户端以统一的方式处理单个对象和对象的组合。组合模式让你可以将对象组合成树形结构&#xff0c;并且能像单独对象一…...

C++发票识别、发票查验接口示例,您的“发票管理专家”

发票识别发票查验接口。当财务人员在进行发票的数字化管理时&#xff0c;仅需一键上传发票图片&#xff0c;翔云发票识别接口即可快速、精准对发票的全票面信息进行提取&#xff0c;翔云发票查验接口可根据识别接口提取的发票信息实时联网进行真伪查验。助财务工作者从发票海洋…...

【电控笔记6.2】拉式转换与转移函数

概要 laplace&#xff1a;单输入单输出&#xff0c;线性系统 laplace 传递函数 总结...

PyFluent:重构CFD仿真流程的技术赋能与效能跃迁

PyFluent&#xff1a;重构CFD仿真流程的技术赋能与效能跃迁 【免费下载链接】pyfluent 项目地址: https://gitcode.com/gh_mirrors/pyf/pyfluent 在现代工程仿真领域&#xff0c;计算流体动力学&#xff08;CFD&#xff09;技术正经历着从手动操作向自动化流程的深刻转…...

华为Matebook 13双系统实战:Win10与Ubuntu 16.04无缝共存指南

1. 为什么选择华为Matebook 13安装双系统 作为一名长期使用双系统开发的工程师&#xff0c;我最近在华为Matebook 13上成功部署了Win10Ubuntu 16.04双系统组合。这款13英寸的轻薄本确实给了我不少惊喜——2K全面屏、1.3kg超轻机身、第八代i5处理器&#xff0c;这些硬件配置对于…...

DeepChat一键启动揭秘:Llama3:8b镜像免配置部署教程(含端口自愈与模型缓存)

DeepChat一键启动揭秘&#xff1a;Llama3:8b镜像免配置部署教程&#xff08;含端口自愈与模型缓存&#xff09; 想体验一个完全私密、响应迅速、且能进行深度对话的AI助手吗&#xff1f;今天&#xff0c;我们将一起揭开DeepChat的神秘面纱。它不是一个需要复杂API密钥和网络调…...

告别Win11无边框窗口的‘残疾’体验:Qt自定义标题栏完美集成Snap Layout保姆级教程

现代Qt应用开发&#xff1a;Win11无边框窗口与Snap Layout深度整合实战 当微软推出Windows 11时&#xff0c;其标志性的Snap Layout功能彻底改变了多窗口管理体验。然而对于使用Qt框架开发无边框窗口应用的开发者来说&#xff0c;这却带来了一个棘手的问题——自定义标题栏与系…...

Stable Diffusion像素艺术工作站:Pixel Fashion Atelier支持LoRA在线热切换

Stable Diffusion像素艺术工作站&#xff1a;Pixel Fashion Atelier支持LoRA在线热切换 1. 像素时装锻造坊简介 Pixel Fashion Atelier是一款基于Stable Diffusion与Anything-v5的图像生成工作站&#xff0c;专为像素艺术创作而设计。与传统AI工具不同&#xff0c;它采用了复…...

AutoUnipus:重新定义U校园学习效率的智能解决方案

AutoUnipus&#xff1a;重新定义U校园学习效率的智能解决方案 【免费下载链接】AutoUnipus U校园脚本,支持全自动答题,百分百正确 2024最新版 项目地址: https://gitcode.com/gh_mirrors/au/AutoUnipus 还在为U校园平台上堆积如山的网课任务而焦虑吗&#xff1f;每天花费…...

WebGLInput:重构Unity WebGL输入体验的革命性方案

WebGLInput&#xff1a;重构Unity WebGL输入体验的革命性方案 【免费下载链接】WebGLInput IME for Unity WebGL 项目地址: https://gitcode.com/gh_mirrors/we/WebGLInput 在Unity WebGL开发中&#xff0c;输入法支持一直是开发者面临的核心挑战之一。WebGLInput项目通…...

4个关键步骤解决Calibre中文路径乱码难题

4个关键步骤解决Calibre中文路径乱码难题 【免费下载链接】calibre-do-not-translate-my-path Switch my calibre library from ascii path to plain Unicode path. 将我的书库从拼音目录切换至非纯英文&#xff08;中文&#xff09;命名 项目地址: https://gitcode.com/gh_m…...

Matlab GUI 计时器:基于定时器对象自动更新的数字时钟演示

Matlab图形用户界面计时器&#xff1a;使用定时器对象自动更新的MatlabGUI&#xff0c;一个数字时钟&#xff0c;作为显示基本组件的快速演示&#xff0c;带有一个按钮&#xff0c;用于恢复/暂停执行更新实验室配了新酶标仪孵箱但总有人&#xff08;比如同组摸鱼的小师妹顺便喊…...

新手避坑指南:用DJI NAZA-LITE飞控组装F450无人机,从焊接电调到GPS校准的完整流程

新手避坑指南&#xff1a;用DJI NAZA-LITE飞控组装F450无人机&#xff0c;从焊接电调到GPS校准的完整流程 第一次组装无人机就像玩一场高风险的拼图游戏——每个零件的位置、每根接线的顺序都可能影响最终能否安全起飞。作为过来人&#xff0c;我清楚地记得焊接电调时锡珠飞溅的…...