题目
这是一个中小公司的面试题:
深圳、招安卓的,薪资能给到 5k。。。
web端效果图:
Android端效果图:
1、技术点拆解
1)Openlayers 是一个JS库,一般只是开发Web,所以第一个技术点是Nodejs,也就是 Web 开发。
2)要在安卓中实现,所以需要使用WebView组件。在Compose中使用WebView我们可以使用AndroiView来包装。
3)因为需要在Android端保存web端的平行线数据到CSV文件,所以此处需要Android端与JS交互。
2、Android Compose端实现:
2.1 新建compose项目(使用基础模板创建)
1)添加相关hilt插件、依赖(app模块下添加):
因为我们使用了文件夹图标、hilt依赖项注入
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
// 添加这两个hilt
id("kotlin-kapt")
id("com.google.dagger.hilt.android")
}
// 引入扩展图标,用于一些文件夹图标之类的
//implementation(libs.androidx.compose.material.iconsExtended)
implementation("androidx.compose.material:material-icons-extended")
// hilt
implementation("com.google.dagger:hilt-android:2.44")
kapt("com.google.dagger:hilt-android-compiler:2.44")
// hilt + compose导航
implementation("androidx.hilt:hilt-navigation-compose:1.2.0")
// Allow references to generated code
kapt {
correctErrorTypes = true
}
2)顶部built添加hilt插件
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
// 依赖项注入 hilt
id("com.google.dagger.hilt.android") version "2.44" apply false
}
说明:上面工作做完之后同步我们就可以在compose里面愉快使用一些图标和hilt依赖项注入了。
2.2 使用 hilt的补充步骤
1)新建一个 Application ,然后添加hilt注解,并把相关类添加到 AndroidMainifest.xml里面:
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
// 使用 hilt 注入,必须提供一个注入的 Application
@HiltAndroidApp
class OLApp : Application() {
}
<application
android:allowBackup="true"
android:name=".OLApp"
2)我们的 MainActivity 也需要加入hilt注解:
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
。。。。
}
3)使用 hilt 制作ViewModel:
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
@HiltViewModel
class OLViewModel @Inject constructor() : ViewModel() {
// 保存平行线数量、间距的状态
var linesCount by mutableIntStateOf(10)
var linesSpace by mutableIntStateOf(10)
}
4)在compose组件中使用 ViewModel 示例:
@Composable
fun OpenLayersWebView(
modifier: Modifier = Modifier,
navController: NavController,
viewModel: OLViewModel = hiltViewModel(),// 使用 hilt 注入
) {
以上就是 hilt 在 Android Compose里面使用的简单基础步骤。
2.2 项目结构设计
1)项目整体结构设计:
2)页面数量,此项目我们可以使用两个页面来制作:
- 页面1:地图网页页面
- 页面2:CSV文件预览页面
2.3 开始编码:此处我直接粘贴代码了
0)导航相关OLNavHost.kt
package lcppx.android.openlayers_compose.navigation
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import lcppx.android.openlayers_compose.screens.csvscreen.BrowseCsvFileScreen
import lcppx.android.openlayers_compose.screens.webscreen.OpenLayersWebView
const val MapRoute = "map"
const val CsvRoute = "csv"
@Composable
fun OLNavHost(
modifier: Modifier = Modifier,
navController: NavHostController,
) {
NavHost(
navController = navController,
// 主页面
startDestination = MapRoute,
modifier = modifier,
) {
composable(
MapRoute
) {
OpenLayersWebView(navController = navController)
}
composable(
CsvRoute
) {
BrowseCsvFileScreen(navController)
}
}
}
1)OpenLayersWebView.kt
package lcppx.android.openlayers_compose.screens.webscreen
import android.annotation.SuppressLint
import android.graphics.Rect
import android.webkit.WebSettings
import android.webkit.WebView
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.navigation.NavController
import lcppx.android.openlayers_compose.R
import lcppx.android.openlayers_compose.navigation.CsvRoute
import lcppx.android.openlayers_compose.ui.components.MapTopAppbar
import lcppx.android.openlayers_compose.ui.components.MyOutlinedTextField
import lcppx.android.openlayers_compose.screens.webscreen.api.CustomWebViewClient
import lcppx.android.openlayers_compose.screens.webscreen.api.JsBridgeInterface
import lcppx.android.openlayers_compose.util.ToastUtil
import androidx.hilt.navigation.compose.hiltViewModel
@OptIn(ExperimentalMaterial3Api::class)
@SuppressLint("SetJavaScriptEnabled")
@Composable
fun OpenLayersWebView(
modifier: Modifier = Modifier,
navController: NavController,
viewModel: OLViewModel = hiltViewModel(),
) {
// 移动到 viewModel
// // 用于存储平行线数量的变量
// var linesCount by remember { mutableIntStateOf(10) }
// // 用于存储平行线间距的变量
// var linesSpacing by remember { mutableIntStateOf(10) }
val context = LocalContext.current
val density = LocalDensity.current
val webView = remember { WebView(context) }
Scaffold(
modifier = Modifier
.windowInsetsPadding(TopAppBarDefaults.windowInsets)
.fillMaxSize(),
// 顶部导航栏
topBar = {
MapTopAppbar() {
navController.navigate(CsvRoute)
}
}
) { innerPadding ->
Column(
modifier = modifier.fillMaxSize().padding(innerPadding)
) {
val url = "file:///android_asset/index.html"
// 1 在compose中实现安卓WebView
AndroidView(
modifier = Modifier.fillMaxWidth().weight(1f),
factory = { context ->
webView.apply {
// 设置WebViewClient以防止外部浏览器打开链接
webViewClient = CustomWebViewClient()// 使用自定义的,在发生错误的时候显示错误页面
// 启用JavaScript
settings.javaScriptEnabled = true
// 通过loadUrl调用JavaScript函数
// loadUrl("javascript:callJs()")
// 是否支持缩放
settings.setSupportZoom(true)
/*settings.domStorageEnabled = true
settings.allowContentAccess = true*/
// 允许WebView加载本地文件,此类设置存在安全问题,所以一般就是测试使用
settings.allowFileAccess = true
// 允许WebView加载来自文件系统的JavaScript(必须提供其中一个)
settings.allowUniversalAccessFromFileURLs = true
settings.allowFileAccessFromFileURLs = true
// 设置混合内容模式(HTTPS和HTTP)
settings.mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
// 加载OpenLayers页面
loadUrl(url)
}
},
// 涉及到安卓端的变量更新,需要在 update 中才能更新
update = {
// 暴露一个名为 Android 的 JavaScript 全局对象
it.addJavascriptInterface(
JsBridgeInterface(context, viewModel.linesCount, viewModel.linesSpace),
"Android"
)// 名为Android的JavaScript对象
ToastUtil.showToast(context, "网页状态重置了")
// 每次状态更新,都重新加载 js 网页:才能将新的状态传给 js 代码
// 但是在此处重新加载也会导致js的网页状态重置,比如键盘弹起的时候都会导致js网页状态重置。。。。
// 所以移动到外部加载
//it.loadUrl(url)
}
)
// 每次 数量、间距更新的时候再重新加载js
LaunchedEffect(viewModel.linesCount, viewModel.linesSpace){
webView.loadUrl(url)
}
/// Compose中使用 AndroidView 的缺点是输入框聚焦的时候无法弹起 AndroidView(里面的内容
/// 如果你想实现弹起也可以自己编写键盘监听逻辑实现弹起,或者不弹起 AndroidView 部分
// 记录键盘当前高度(px)
var imCurrentHeightPx by remember { mutableIntStateOf(0) }
// 监听高度变化(监听View)
val view = LocalView.current// 当前的View
view.viewTreeObserver.addOnGlobalLayoutListener {
val rect = run {
val rect = Rect()
view.getWindowVisibleDisplayFrame(rect)
rect
}
val heightDiff = view.rootView.height - rect.bottom//页面高度差( = 键盘高度)
imCurrentHeightPx = if (heightDiff > 0) {
// 注意,键盘收起的时候,他不是0,而是保持和底部安全距离。
heightDiff
} else {
0
}
}
// 2 底部的两个输入框(悬浮在最上层)
Column(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.background)
.padding(horizontal = 16.dp)
.padding(bottom = with(density) { imCurrentHeightPx.toDp() }),// 跟随键盘弹起而弹起
) {
// 平行线数量输入框
MyOutlinedTextField(
number = viewModel.linesCount,
setNumber = {
viewModel.linesCount = it
},
labelStr = stringResource(R.string.label_lines_count)//"平行线数量(默认10条)"
)
// 平行线间距输入框
MyOutlinedTextField(
number = viewModel.linesSpace,
setNumber = {
viewModel.linesSpace = it
},
labelStr = stringResource(R.string.label_lines_spacing)//"平行线间距(默认10米)"
)
}
}
}
}
OLViewModel.kt
package lcppx.android.openlayers_compose.screens.webscreen
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
@HiltViewModel
class OLViewModel @Inject constructor() : ViewModel() {
// 保存平行线数量、间距的状态
var linesCount by mutableIntStateOf(10)
var linesSpace by mutableIntStateOf(10)
}
CustomWebViewClient.kt
package lcppx.android.openlayers_compose.screens.webscreen.api
import android.graphics.Bitmap
import android.os.Build
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
/**
* 自定义 WebViewClient,实现发生错误的时候显示错误页面
* */
class CustomWebViewClient : WebViewClient() {
// 重写shouldOverrideUrlLoading方法(带 url 的),打开网络链接的时候走我们自己app
// 你也可以在此做一些初始化操作
@Deprecated("Deprecated in Java")
override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean {
view.loadUrl(url)
return true
}
// 开始载入页面的时候,你可以设置一个 Loding 页面
// 页面加载结束,你可以重写 onPageFinished()方法,进行一些页面加载完成后的操作
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
super.onPageStarted(view, url, favicon)
// todo Loading...
}
// 当发生错误的时候,打印错误信息,并且加载至错误页面
override fun onReceivedError(
view: WebView?,
request: WebResourceRequest?,
error: WebResourceError?
) {
super.onReceivedError(view, request, error)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
println("发生错误,错误信息:${error?.description},错误码:${error?.errorCode}")
}
// 加载自定义错误页面
//view?.loadUrl("file:///android_asset/dist/error.html")
}
}
JsBridgeInterface.kt
package lcppx.android.openlayers_compose.screens.webscreen.api
import android.annotation.SuppressLint
import android.content.Context
import android.webkit.JavascriptInterface
import lcppx.android.openlayers_compose.util.CsvUtils
import lcppx.android.openlayers_compose.util.ToastUtil
import org.json.JSONArray
import org.json.JSONException
// 定义JavaScript 可以调用 kotlin 的接口
class JsBridgeInterface(
private val context: Context,
private val linesCount: Int,
private val linesSpace: Int,
) {
@JavascriptInterface
fun showToast(message: String) {
// todo 测试————尝试在js里面显示安卓 T
ToastUtil.showToast(context, message)
}
@SuppressLint("DefaultLocale")
@JavascriptInterface
fun sendParallelLinesCoordinates(coordinateArrayJson: String) {
try {
// 一行数据的格式为:[[114.05769097420503,22.54319555952454],[114.0601532420807,22.543603255294805]],
// 也就是起点坐标、终点坐标,然后两者组成的List
val jsonArray = JSONArray(coordinateArrayJson)
val lines = mutableListOf<Pair<Pair<String, String>, Pair<String, String>>>()
for (i in 0 until jsonArray.length()) {
val coordinateJson = jsonArray.getJSONArray(i)
val startJsonArray = coordinateJson.getJSONArray(0)
val endJsonArray = coordinateJson.getJSONArray(1)
// 保留 5 位小数
val startLat = String.format("%.5f", startJsonArray.getDouble(1))
val startLng = String.format("%.5f", startJsonArray.getDouble(0))
val endLat = String.format("%.5f", endJsonArray.getDouble(1))
val endLng = String.format("%.5f", endJsonArray.getDouble(0))
val start = Pair(startLng, startLat)
val end = Pair(endLng, endLat)
/*val start = Pair(startJsonArray.getString(0), startJsonArray.getString(1))
val end = Pair(endJsonArray.getString(0), endJsonArray.getString(1))*/
lines.add(Pair(start, end))
}
// 处理coordinatesList,例如保存或显示
// 将坐标保存到CSV文件
CsvUtils.saveLinesToCsv(
lines,
"parallel_lines.csv",
context)
} catch (e: JSONException) {
e.printStackTrace()
// 处理错误
}
}
// 在 js 里面调用这个函数,传回来两个
// aPointX: String, aPointY: String,bPointX: String, bPointY: String,
// 只能接收基本数据类型(如 String、int、boolean 等)或者 JSONArray 和 JSONObject 作为参数,而不能直接接收 Kotlin 的 List 类型。
@JavascriptInterface
fun sendCoordinatesToKotlin(jsonArray: JSONArray) {//lines:List<List<String>>
ToastUtil.showToast(context,"发送坐标到kotlin")
// 解析字符串并获取坐标
// 将 JSONArray 转换为 List<List<String>>
val lines = jsonArray.toKotlinList()
}
/* JS 端使用说明:
// 发送所有行的坐标到 kotlin
function sendCoordinatesToKotlin(coordinates) {
// JavaScript中,我们通常使用JavaScript原生的数组(Array)来处理类似JSON数组的数据结构。
const jsonArray = [["element1", "element2", "element3"],["element1", "element2", "element3"],...];
coordinates.forEach(function(line) {
const lineArray = [];
line.forEach(function(coord) {
lineArray.put(coord);
});
jsonArray.put(lineArray);
});
Android.sendCoordinatesToKotlin(jsonArray);
}*/
// js 里面需要获取 安卓原生两个输入框的值,所以此处给出所需的接口
@JavascriptInterface
fun getLinesCount():Int {
return linesCount
}
@JavascriptInterface
fun getLinesSpace():Int {
return linesSpace
}
}
fun JSONArray.toKotlinList(): List<List<String>> {
// 创建了一个MutableList来存储最终的结果
val list = mutableListOf<List<String>>()
try {
// 遍历JSONArray中的每个元素,每个元素本身也是一个JSONArray
for (i in 0 until this.length()) {
val innerArray = this.getJSONArray(i)
// 创建了一个MutableList来存储字符串,并将其添加到外部列表中
val innerList = mutableListOf<String>()
for (j in 0 until innerArray.length()) {
innerList.add(innerArray.getString(j))
}
list.add(innerList)
}
} catch (e: JSONException) {
e.printStackTrace()
}
return list
}
// 简单测试
fun toKotlinListTest(){
val jsonArray = JSONArray("[[\"A1\",\"A2\",\"A3\"],[\"B1\",\"B2\",\"B3\"]]")
val kotlinList = jsonArray.toKotlinList()
println(kotlinList) // 输出: [[A1, A2, A3], [B1, B2, B3]]
}
2)BrowseCsvFileScreen.kt
package lcppx.android.openlayers_compose.screens.csvscreen
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.FileOpen
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FabPosition
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import lcppx.android.openlayers_compose.ui.components.RowVCenter
import lcppx.android.openlayers_compose.util.CsvUtils.readCsvFile
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
fun BrowseCsvFileScreen(
navController: NavController,
) {
val context = LocalContext.current
var csvFilePath by remember { mutableStateOf(context.filesDir.path + "/parallel_lines.csv") }
val csvContent = remember(csvFilePath) { mutableStateOf<List<List<String>?>>(emptyList()) }
// 读取CSV文件内容
LaunchedEffect(key1 = csvFilePath) {
csvContent.value = readCsvFile(context, csvFilePath)
println("读取到的csv文件内容: ${csvContent.value.size}:${csvContent.value}")
}
val listState = rememberLazyListState()
val expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } }
val sheetState = rememberModalBottomSheetState()
val scope = rememberCoroutineScope()
var showBottomSheet by remember { mutableStateOf(false) }
// 获取屏幕大小
val configuration = LocalConfiguration.current
val screenHeight = configuration.screenHeightDp.dp
// 底部文件列表
if (showBottomSheet) {
ModalBottomSheet(
onDismissRequest = {
showBottomSheet = false
},
sheetState = sheetState
) {
var onlyCsvFile by remember { mutableStateOf(true) }
Box(
modifier = Modifier.fillMaxWidth().combinedClickable { onlyCsvFile = !onlyCsvFile },
) {
RowVCenter(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(text = if (onlyCsvFile) "仅显示Csv文件" else "显示所有文件")
Switch(
checked = onlyCsvFile,
onCheckedChange = {
onlyCsvFile = it
}
)
}
}
FileListScreen(
modifier = Modifier.fillMaxWidth().height(screenHeight * 0.5f),
onClickCsvFile = { selectedFilePath ->
csvFilePath = selectedFilePath
showBottomSheet = false
},
onlyCsvFile = onlyCsvFile,
)
}
}
Scaffold(
floatingActionButton = {
ExtendedFloatingActionButton(
onClick = { showBottomSheet = true },
expanded = expandedFab,
icon = { Icon(Icons.Filled.FileOpen, "Localized Description") },
text = { Text(text = "文件列表") },
)
},
floatingActionButtonPosition = FabPosition.End,
) {
// UI部分
Column(
modifier = Modifier.fillMaxSize().padding(it),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top
) {
// 顶部导航栏
RowVCenter(
modifier = Modifier.fillMaxWidth().height(66.dp)
) {
// 返回按钮
IconButton(
onClick = {
// 返回上一级
navController.popBackStack()
}
) {
Icon(
imageVector = Icons.Filled.ArrowBack,
contentDescription = "返回上一级",
modifier = Modifier.size(24.dp)
)
}
// 标题、文件路径名
Column(
modifier = Modifier.fillMaxWidth().padding(6.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "CSV文件简单预览测试",
style = MaterialTheme.typography.headlineSmall
)
Text(text = csvFilePath, fontSize = 11.sp)
}
}
HorizontalDivider()
Spacer(modifier = Modifier.height(16.dp))
// 显示CSV文件内容(所有行)
LazyColumn(
state = listState,
) {
itemsIndexed(csvContent.value ?: emptyList()) { index, line ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
// 1、前面的序号
Text(text = "${if (index > 0)index-1 else ""}")
// 2、后面的数据
line?.forEach { item ->
Text(
item,
maxLines = 1,
// 第一行数据标红,表示这是原来的线条AB
color = if (index == 1) Color.Red else Color.Black,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
}
}
}
FileListScreen.kt
package lcppx.android.openlayers_compose.screens.csvscreen
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.FileOpen
import androidx.compose.material.icons.rounded.Folder
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import lcppx.android.openlayers_compose.ui.components.RowVCenter
import lcppx.android.openlayers_compose.util.FileUtils
import java.io.File
// 用于测试的简单文件列表,用于显示存储的 csv 文件
@Composable
fun FileListScreen(
modifier: Modifier = Modifier,
onClickCsvFile: (String) -> Unit,
onlyCsvFile: Boolean = true
) {
val context = LocalContext.current
val fileList = remember(onlyCsvFile) {
var fl = context.filesDir.listFiles()?.toList()?: emptyList()
// 是否仅显示 csv 文件
fl = if(onlyCsvFile) fl.filter { it.isFile && it.extension == "csv" } else fl
// 按照时间逆向排序
fl.sortedByDescending { it.lastModified() }
}
LazyColumn(
modifier = modifier
) {
items(fileList) { file ->
FileLazyItem(file,onClickCsvFile)
}
}
}
// 每一项文件UI:
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun FileLazyItem(
file: File,
onClickCsvFile: (String) -> Unit
) {
val isFile = if(file.isFile) true else false
Column {
RowVCenter(
modifier = Modifier.fillMaxWidth().combinedClickable {
onClickCsvFile(file.absolutePath)
}
) {
// 1、文件图标
Icon(
imageVector = if (isFile) Icons.Rounded.FileOpen else Icons.Rounded.Folder,
contentDescription = "",
modifier = Modifier.padding(horizontal = 13.dp).size(33.dp)
)
// 2、文件信息
Column(
modifier = Modifier.weight(1f).padding(8.dp)
) {
// 2.1 文件名
Text(
text = file.name,
maxLines = 3,
fontSize = 18.sp,
fontWeight = FontWeight.Bold
)
// 2.2 文件创建时间、文件大小
RowVCenter {
Text(text = FileUtils.formatDateTime(file.lastModified()))
Text(text = " ${FileUtils.formatSize(file.length())}")
}
}
}
HorizontalDivider()
}
}
组件相关
1)顶部导航栏
MapTopAppbar.kt
package lcppx.android.openlayers_compose.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.PlaylistAddCircle
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import lcppx.android.openlayers_compose.ui.theme.Typography
// 顶部导航栏
@Composable
fun MapTopAppbar(
onClickCsv:()->Unit
) {
RowVCenter(
modifier = Modifier.fillMaxWidth().height(50.dp).background(Color.White.copy(0.33f)),
horizontalArrangement = Arrangement.SpaceBetween
) {
@Composable
fun topIcon(
imageVector: ImageVector?,
onClick: () -> Unit = {},
){
IconButton(
onClick = onClick
) {
if (imageVector != null) {
Icon(
imageVector = imageVector,
contentDescription = ""
)
}
}
}
// 1 左侧菜单按钮
// topIcon(Icons.Default.Menu){
//
// }
topIcon(null)// 占位
// 2 中间标题
Text("Openlayers测试", style = Typography.titleLarge)
// 3 右侧更多按钮
topIcon(Icons.Default.PlaylistAddCircle){
onClickCsv()
}
}
}
2)输入框组件
MyOutlinedTextField.kt
package lcppx.android.openlayers_compose.ui.components
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
// 输入框组件
@Composable
fun MyOutlinedTextField(
number:Int,
setNumber:(Int)->Unit,
labelStr:String,
){
OutlinedTextField(
modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp),
value = "$number",
onValueChange = { newValue ->
// 添加一个检查,以确保用户输入的是有效的正数。
// 如果输入无效,我们可以忽略该输入或将其重置为默认值
try {
val parseInt = newValue.toInt()
if (parseInt > 0) {
setNumber(parseInt)
}
} catch (e: NumberFormatException) {
// 如果输入不是数字,则不更新状态
}
},
label = { Text(labelStr) },
singleLine = true
)
}
辅助的布局组件
RowVCenter.kt
package lcppx.android.openlayers_compose.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
// 在垂直方向上中心对齐的Compose组件,经常用于顶部导航栏等常见场景
@Composable
fun RowVCenter(
modifier: Modifier = Modifier,
horizontalArrangement: Arrangement.Horizontal = Arrangement.Start,
verticalAlignment: Alignment.Vertical = Alignment.CenterVertically,// 垂直方向上默认中心对齐
content: @Composable() (RowScope.() -> Unit),
) {
Row(
modifier = modifier,
horizontalArrangement = horizontalArrangement,
verticalAlignment = verticalAlignment,
content = content,
)
}
工具类
1)CsvUtils.kt
package lcppx.android.openlayers_compose.util
import android.content.Context
import java.io.BufferedReader
import java.io.File
import java.io.FileReader
import java.io.FileWriter
import java.io.IOException
object CsvUtils {
/**
* 保存坐标数据到CSV文件
* @param lines 包含线的坐标数据列表,每一行都有一对坐标A、B。然后每个坐标都包含经度、纬度一对值
* @param fileName 要保存的CSV文件名
* @param context 上下文,用于访问文件系统
*/
fun saveLinesToCsv(lines: List<Pair<Pair<String,String>,Pair<String,String>>>, fileName: String, context: Context) {
try {
// /data/data/your.package.name/files/xxx
//val folderFile = File("/storage/emulated/0")
// val file = File(folderFile, fileName)
val file = File(context.filesDir, fileName)
val writer = FileWriter(file)
// 写入CSV文件头部
writer.append("A点经度,A点纬度,B点经度,B点纬度\n")
// 写入每条线的坐标数据
for (line in lines) {
writer.append("${line.first.first},${line.first.second},${line.second.first},${line.second.second}")
writer.append("\n")
}
// 关闭文件写入器
writer.close()
} catch (e: IOException) {
e.printStackTrace()
}
}
// 读取CSV文件的辅助函数
fun readCsvFile(context: Context, filePath: String): List<List<String>?> {
val lines = mutableListOf<List<String>?>()
val file = File(filePath)
try {
BufferedReader(FileReader(file)).useLines { linesIterator ->
linesIterator.forEach { line ->
val values = line.split(",")
lines.add(values)
}
}
} catch (e: Exception) {
e.printStackTrace()
}
return lines
}
}
// 简单的测试
fun CsvSaverTest(context: Context){
println("测试CSV保存")
val linesData = listOf(
Pair(Pair("116.397428", "39.90923"), Pair("116.400428", "39.90123")),
Pair(Pair("116.405428", "39.91523"), Pair("116.407428", "39.91723"))
)
val linesData2 = listOf(
Pair(Pair("116.397428", "39.90923"), Pair("116.400428", "39.90123")),
Pair(Pair("116.397428", "39.90923"), Pair("116.400428", "39.90123")),
Pair(Pair("116.397428", "39.90923"), Pair("116.400428", "39.90123")),
Pair(Pair("116.397428", "39.90923"), Pair("116.400428", "39.90123")),
Pair(Pair("116.405428", "39.91523"), Pair("116.407428", "39.91723"))
)
CsvUtils.saveLinesToCsv(linesData, "lines.csv", context)
CsvUtils.saveLinesToCsv(linesData2, "lines2.csv", context)
}
FileUtils.kt
package lcppx.android.openlayers_compose.util
import android.annotation.SuppressLint
import java.text.SimpleDateFormat
import java.util.Date
import kotlin.math.log10
import kotlin.math.pow
object FileUtils {
// 大小:比较简单的写法
@SuppressLint("DefaultLocale")
fun formatSize(size: Long): String {
if (size<=0L) return "0B"
// 仅需处理 大于 0 的情况
val units = arrayOf("B", "KB", "MB", "GB", "TB")
val digitGroups = (log10(size.toDouble()) / log10(1024.0)).toInt()
return String.format("%.2f %s", size / (1024.0.pow(digitGroups.toDouble())), units[digitGroups.coerceIn(0,4)])
}
// 时间
@SuppressLint("SimpleDateFormat")
fun formatDateTime(timestamp: Long,pattern:String = "yy-MM-dd HH:mm"): String {
val patterns = listOf(
"yyyy-MM-dd HH:mm:ss",
"yyyy-MM-dd HH:mm",
"yyyy-MM-dd",
"yyyy-MM",
"yyyy",
"HH:mm:ss",
"HH:mm",
)
// DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT).format(Date(timestamp))// 使用中、短格式,可以自动适应系统语言
//return SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(Date(timestamp))// 自定义,固定
return SimpleDateFormat(pattern).format(Date(timestamp))// 自定义,固定
// return SimpleDateFormat("yy/MM-dd HH:mm").format(Date(timestamp))// 自定义,固定
// return SimpleDateFormat("yy/MM/dd HH:mm").format(Date(timestamp))// 自定义,固定
}
}
权限相关:
备注:不一定需要,因为如果只保存至应用目录,可能只需要简单的存储权限(甚至不需要—我还没测试过)
StoragePermissionUtils.kt
package lcppx.android.openlayers_compose.util
import android.Manifest
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.Settings
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
object StoragePermissionUtils {
/** Android 存储权限请求情况:
* 1、Android 6.0(23) ~ Android10(29)申请权限
* */
// 1 定义存储相关权限数组(3个字符串)
val perms = arrayOf(
// 2 个与存储相关的
Manifest.permission.READ_EXTERNAL_STORAGE,
Manifest.permission.WRITE_EXTERNAL_STORAGE,
//Manifest.permission.READ_PHONE_STATE// 手机设备相关
)
/**
* 检查是否已经获取存储权限或全部文件访问权限,没有就获取
* @param context 应用程序或活动的上下文。
* @return 如果已经获取所需的存储权限,则返回true;否则返回false。
*/
fun requestStorageOrAllFilePermission(activity: Activity): Boolean {
// 安卓 11 以及以上,只需要申请所有文件访问权限
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
// 如果没有管理外部存储的权限,就申请// 所有文件访问权限
requestAllFilesAccessPermission(activity)
return Environment.isExternalStorageManager()
}else
// >= Android 6.0 才需要动态申请
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
// 遍历字符串数组,逐一检查权限,如果没有,就跳转去设置页面手动设置权限
for (p in perms) {
val ret = ContextCompat.checkSelfPermission(activity, p)
// 如果权限未获取,就先申请获取权限
if (ret != PackageManager.PERMISSION_GRANTED) {
//TODO 跳转到权限页,手动设置权限
//goAppDetailsSettings(context)
// 请求存储权限
ActivityCompat.requestPermissions(
activity,
perms,
PERMISSIONS_REQUEST_CODE
)
return true
}
}
}
return false
}
private const val PERMISSIONS_REQUEST_CODE = 100 // 你自定义的请求码
const val REQUEST_CODE_ALL_FILES_ACCESS = 108
/**
* Android 11及以上版本请求所有文件访问权限的方法。
* 需要在调用此方法的Activity中重写onActivityResult方法来处理用户的选择结果。
* @param activity 当前Activity实例。
*/
private fun requestAllFilesAccessPermission(activity: Activity) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && !Environment.isExternalStorageManager()) {
//val intent = Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION)// 这个请求会跳到全部应用页面,体验不好
val appIntent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION)// 直接跳到当前app授权页面
appIntent.setData(Uri.parse("package:" + activity.packageName))
activity.startActivityForResult(appIntent, REQUEST_CODE_ALL_FILES_ACCESS)
}
}
/** 跳转到“应用信息”页面:
* 安卓默认只能跳转到 "应用信息"页面,
* 但是国内手机厂商大多支持各自自定义的Intent,直接跳到应用程序权限页面
* 当前应用详情页面(在该页面单击权限,进入的是权限组页面)
*/
private fun goAppDetailsSettings(context: Context) {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
intent.setData(Uri.fromParts("package", context.packageName, null))
context.startActivity(intent)
}
}
ToastUtil.kt
package lcppx.android.openlayers_compose.util
import android.content.Context
import android.widget.Toast
/*
* 快速连续点击了五次按钮,Toast就触发了五次。这样的体验其实是不好的,因为也许用户是手抖了一下多点了几次,
* 导致Toast就长时间关闭不掉了。又或者我们其实已在进行其他操作了,应该弹出新的Toast提示,而上一个Toast却还没显示结束。
* 因此,最佳的做法是将Toast的调用封装成一个接口,写在一个公共的类当中,如下所示:
*
* 这样就相当于共用一个全局Toast,当他是空的才新建。
* */
object ToastUtil {
private var toast: Toast? = null
// 适用于短暂的、用户可能频繁点击的提示
fun showToast(
context: Context?,
content: String?,
) {
// 取消之前的Toast
toast?.cancel()
// 创建并显示新的Toast
toast = Toast.makeText(context, content, Toast.LENGTH_SHORT).apply {
show()
}
}
// 常规的,不取消之前的
fun showToast2(
context: Context?,
content: String?,
) {
Toast.makeText(
context,
content,
Toast.LENGTH_SHORT
)!!.show()
}
}
MainyActivity.kt
package lcppx.android.openlayers_compose
import android.annotation.SuppressLint
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.compose.rememberNavController
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import lcppx.android.openlayers_compose.navigation.CsvRoute
import lcppx.android.openlayers_compose.navigation.OLNavHost
import lcppx.android.openlayers_compose.ui.components.MapTopAppbar
import lcppx.android.openlayers_compose.screens.csvscreen.BrowseCsvFileScreen
import lcppx.android.openlayers_compose.ui.theme.OpenlayerscomposeTheme
import lcppx.android.openlayers_compose.util.CsvSaverTest
import lcppx.android.openlayers_compose.util.StoragePermissionUtils
import lcppx.android.openlayers_compose.util.ToastUtil
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val that = this // 保存当前 Activity 引用
lifecycleScope.launch {
// 生命周期处理:Activity启动的时候,检查并申请存储、或者所有文件访问权限
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
//ToastUtil.showToast(that,"start")
// 请求存储权限、或者所有文件访问权限,用于保存 CSV 文件
StoragePermissionUtils.requestStorageOrAllFilePermission(that)
}
}
enableEdgeToEdge()
setContent {
val navController = rememberNavController()
OpenlayerscomposeTheme {
//CsvSaverTest(this)// 测试使用
OLNavHost(
navController = navController,
)
}
}
}
}
3、web端js、html、css实现(nodejs)
项目结构和核心代码:
3.1 web项目构建:
一般需要你先搭建 nodejs 运行环境,还有vite构建工具,此处不多赘述,相信看这个教程的你已经不是web小白。
然后运行 openlayers 推荐的项目基本模板:
npx create-ol-app my-app --template vite
测试的时候你可以使用如下命令:
cd my-app
npm start
构建的时候你需要使用如下命令,此命令会打包项目到根目录的 dist目录下,这就是我们最终放置到Android端 assets 目录下的文件
3.3 js目录核心代码实现
1)externalAPI.js
/** 其他实验性api,比如调用 Android 原生代码
*
* */
// js 调用 安卓 kotlin 代码的示例
export function callAndroidMethods(str) {
const lineCount = Android.getLinesCount();
Android.showToast(`JavaScript:${lineCount}==>${str}`);
}
// 向 安卓提供的 js 函数,由 安卓kotlin 调用
export function provideJsMethods() {
return "你执行了Js函数的代码"
}
// 发送所有行的坐标到 kotlin
// export function sendCoordinatesToKotlin(coordinates) {
// // JavaScript中,我们通常使用JavaScript原生的数组(Array)来处理类似JSON数组的数据结构。
// //const coordinates = [["element1", "element2", "element3"], ["element1", "element2", "element3"],];
// const jsonArray = [];
// coordinates.forEach(function (line) {
// const lineArray = [];
// line.forEach(function (coord) {
// lineArray.put(coord);
// });
// jsonArray.put(lineArray);
// });
// }
// 发送所有行的坐标到 kotlin
export function sendCoordinatesToKotlin(coordinates) {
// 将parallelLinesCoordinates数组转换为JSON字符串
const parallelLinesCoordinatesJson = JSON.stringify(coordinates);
console.log(`测试转换后的json字符串为 ${parallelLinesCoordinatesJson}`)
/** 测试结果记录:每一行有2个点的坐标
测试转换后的json字符串为
[
[[114.05769097420503,22.54319555952454],[114.0601532420807,22.543603255294805]],
[[114.05776434556483,22.542752435127806],[114.0602266134405,22.54316013089807]],
[[114.05774967129288,22.542841060007156],[114.06021193916855,22.54324875577742]],
[[114.05773499702092,22.542929684886502],[114.06019726489659,22.543337380656766]],
[[114.05772032274895,22.54301830976585],[114.06018259062462,22.543426005536112]],
[[114.057705648477,22.543106934645195],[114.06016791635267,22.54351463041546]],
[[114.05767629993306,22.543284184403888],[114.06013856780874,22.54369188017415]],
[[114.05766162566111,22.543372809283234],[114.06012389353678,22.543780505053498]],
[[114.05764695138915,22.54346143416258],[114.06010921926482,22.543869129932844]],
[[114.05763227711718,22.543550059041927],[114.06009454499285,22.54395775481219]],
[[114.05761760284523,22.543638683921277],[114.0600798707209,22.54404637969154]]]*/
Android.sendParallelLinesCoordinates(parallelLinesCoordinatesJson);
}
2)buttonEvents.js
/** 按钮事件处理
* 定义按钮事件处理逻辑。
* */
import {fromLonLat} from 'ol/proj';
import {gaodeTileLayer, testTileLayer} from "./mapLayers";
///=====================平移、旋转、缩放按钮事件=============================
// 定义常量
// const MOVE_STEP = 5200; // 移动步数,单位与地图视图坐标系一致
const MOVE_STEP = 5; // 移动步数,单位与地图视图坐标系一致,使用经纬度参考系需要使用较小的值
const ROTATION_ANGLE = 10; // 旋转角度,单位为度
const ZOOM_LEVEL_CHANGE = 1; // 缩放级别变化量
// 获取这些定义在 html 里面的按钮列表
const btns = document.querySelectorAll(".btns button")
const moveTopButton = btns[0]; // 上移按钮
const moveBottomButton = btns[1]; // 下移按钮
const moveLeftButton = btns[2]; // 左移按钮
const moveRightButton = btns[3]; // 右移按钮
const rotateClockwiseButton = btns[4]; // 顺时针旋转按钮
const rotateCounterClockwiseButton = btns[5]; // 逆时针旋转按钮
const zoomInButton = btns[6]; // 放大按钮
const zoomOutButton = btns[7]; // 缩小按钮
const changeLayerButton = btns[8]; // 切换地图数据图层
// 按钮事件处理
export function setupButtonEvents(map) {
// 上移
moveTopButton.onclick = () => {
const view = map.getView()
// todo 注意,使用经纬度参考坐标系,需修改移动步数。因为经纬度的范围会相对很小
const viewCenter = view.getCenter()
viewCenter[1] -= MOVE_STEP;
view.setCenter(viewCenter);
map.render();// 移动后触发地图重新渲染
};
// 下移
moveBottomButton.onclick = () => {
const view = map.getView()
// todo 注意,使用经纬度参考坐标系,需修改移动步数。因为经纬度的范围会相对很小
const viewCenter = view.getCenter()
viewCenter[1] += MOVE_STEP
view.setCenter(viewCenter)
map.render()// 移动后触发地图重新渲染
}
// 左移
moveLeftButton.onclick = () => {
const view = map.getView()
// todo 注意,使用经纬度参考坐标系,需修改移动步数。因为经纬度的范围会相对很小
const viewCenter = view.getCenter()
viewCenter[0] -= MOVE_STEP
view.setCenter(viewCenter)
map.render()// 移动后触发地图重新渲染
}
// 右移
moveRightButton.onclick = () => {
const view = map.getView()
// todo 注意,使用经纬度参考坐标系,需修改移动步数。因为经纬度的范围会相对很小
const viewCenter = view.getCenter()
viewCenter[0] += MOVE_STEP
view.setCenter(viewCenter)
map.render()// 移动后触发地图重新渲染
}
// 顺时针旋转
rotateClockwiseButton.onclick = () => {
const view = map.getView()
const rotation = view.getRotation() || 0; // 如果当前没有旋转,则默认为0
view.setRotation(rotation + Math.PI / 180 * ROTATION_ANGLE); // 旋转10度
map.render(); // 旋转后触发地图重新渲染
};
// 逆时针旋转
rotateCounterClockwiseButton.onclick = () => {
const view = map.getView()
const rotation = view.getRotation() || 0; // 如果当前没有旋转,则默认为0
view.setRotation(rotation - Math.PI / 180 * ROTATION_ANGLE); // 旋转-10度
map.render(); // 旋转后触发地图重新渲染
};
// 放大
zoomInButton.onclick = () => {
const view = map.getView()
const zoom = view.getZoom();
view.setZoom(zoom + ZOOM_LEVEL_CHANGE); // 增加1级的缩放
map.render(); // 缩放后触发地图重新渲染
};
// 缩小
zoomOutButton.onclick = () => {
const view = map.getView();
const zoom = view.getZoom();
view.setZoom(zoom - ZOOM_LEVEL_CHANGE); // 减少1级的缩放
map.render(); // 缩放后触发地图重新渲染
};
changeLayerButton.onclick = () => {
// 此处并未实现切换功能,只是简单提供切换到测试数据,因为我的高德地图居然在手机上无法演示运行
map.removeLayer(gaodeTileLayer)
map.addLayer(testTileLayer)
map.render(); // 缩放后触发地图重新渲染
};
}
///=====================平移、旋转、缩放按钮事件=============================
3)mapInteractions.js
/** 地图交互
* 定义地图的交互行为,如平移、旋转、缩放。
* */
import {DragRotateAndZoom, defaults as defaultInteractions,} from 'ol/interaction';
import {FullScreen, defaults as defaultControls} from 'ol/control';
// 添加【拖动、旋转、缩放】快捷交互(电脑上按住 shift + 鼠标操作)
const quickInteractions = defaultInteractions().extend([new DragRotateAndZoom()]);
// 添加全屏控制(如果地图上没有按钮,则表示您的浏览器不支持全屏 API)
const controls = defaultControls().extend([new FullScreen()]);
export {quickInteractions, controls};
4)mapLayers.js
/* 地图图层
* 定义和初始化各种地图图层,包括瓦片图层和矢量图层。
* 测试使用的 OSM瓦片地图、百度地图瓦片图层、高德地图瓦片图层
* */
import {Tile as TileLayer, Vector as VectorLayer} from 'ol/layer';
import {OSM, ImageTile} from 'ol/source';
import {fromLonLat, get as getProj} from 'ol/proj';
import {TileGrid} from "ol/tilegrid";
///=========================连接公共地图图层==========================
/// 1、测试使用的瓦片地图(此处使用OSM作为在线地图数据源进行测试),大部分情况可能需要科学上网
const testTileLayer = new TileLayer({source: new OSM()})
/// 2、百度地图瓦片图层// todo 百度地图实在无法显示,不知道是否url已经被禁用...暂时发现百度瓦片地图真是一个坑待填...
// 百度瓦片图层对象//todo:暂时用不了...
const baiduTileLayer = new TileLayer({
// 连接百度地图的瓦片地图数据源地址
source: new ImageTile(//TileImage(
{
projection: getProj("EPSG:3857"),// 设置为坐标参考系(而不是经纬度参考系)
// 分辨率(瓦片网格)
tileGrid: new TileGrid({
origin: [0, 0],
// 计算分辨率数组
resolutions: Array.from({length: 19}, (_, i) => Math.pow(2, 18 - i))
}),
tileUrlFunction: function (tileCoord, pixelRadio, proj) {
// 处理百度地图的瓦片地图请求地址,需要手动构造部分参数...
const z = tileCoord[0];
let x = tileCoord[1];
let y = -tileCoord[2] - 1;
if (x < 0) x = 'M' + (-x);
if (y < 0) y = 'M' + (-y);
return `http://online3.map.bdimg.com/onlinelabel/?qt=tile&x=${x}&y=${y}&z=${z}&styles=pl&udt=20151021&scaler=1&p=1`
}
}
)
})
/// 3、高德地图瓦片图层。可用
const gaodeTileLayer = new TileLayer({
// source: new XYZ( url),//todo 好像高版本的XYZ里面没有url这个了,所以此处改用 ImageTile 试试
source: new ImageTile({
url: "http://wprd0{1-4}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&style=7&x={x}&y={y}&z={z}",
wrapX: false
}),
})
///=========================连接公共地图图层==========================
export {testTileLayer, baiduTileLayer, gaodeTileLayer};
5)vectorDrawing.js
/** 矢量图形绘制
* 定义绘制点、线矢量图形的逻辑。
* */
import {Map, Feature} from 'ol';
import {Vector as VectorSource} from 'ol/source';
import {Tile as TileLayer, Vector as VectorLayer} from "ol/layer";
import {fromLonLat, get as getProj} from 'ol/proj';
// 图形相关
import {LineString, Point} from "ol/geom";
// 样式相关
import {Circle, Fill, Icon, Stroke, Style} from 'ol/style';
import {sendCoordinatesToKotlin} from "./helper/externalAPI";
const vectorSource = new VectorSource({wrapX: false});
const vectorLayer = new VectorLayer({
source: vectorSource,
style: new Style({
stroke: new Stroke({
color: 'blue',
width: 3
})
})
});
/**
* 在地图上绘制一个点要素,并最终添加到地图上
* @param {Map} map - OpenLayers 地图实例
* @param {Coordinate} coordinate - 点的坐标,格式为 [经度, 纬度]
* @param {Style} style - 点的样式
*
* @returns {Feature} 返回创建的点要素对象
*/
function drawPointOnMap(map, coordinate, style) {
console.log(`在地图上开始绘制点...coordinate = ${coordinate} ===》${fromLonLat(coordinate)}`)
/** 绘制点要素的流程算法:
* 1、通过“几何信息” 来 new 一个 Feature 对象
* 2、为这个 Feature 对象设置样式*/
// 1、创建一个点几何点对象
// const pointGeometry = new Point(fromLonLat(coordinate));// 提示:不需要转换!
const pointGeometry = new Point(coordinate);
// 2、创建一个特征对象,包含几何对象和样式
const pointFeature = new Feature({
geometry: pointGeometry,
// 可以添加其他属性,例如点的名称等
name: 'Point'
});
// 3、设置该点的样式
// 点的默认样式
const defaultStyle = new Style({
image: new Circle({
radius: 6,// 半径
// 填充颜色
fill: new Fill({
color: "#ff2d51"
}),
// 描边效果
stroke: new Stroke({
width: 1,
color: "#233"
})
}
)
});
pointFeature.setStyle(!style ? defaultStyle : style);
// 4、创建一个矢量源,并将要素添加到矢量数据源中:
const vectorSource = new VectorSource({
features: [pointFeature]
});
// 5、创建一个矢量图层,并设置矢量源
const vectorLayer = new VectorLayer({
source: vectorSource
});
// 6、将矢量图层添加到地图
map.addLayer(vectorLayer);
// 最后返回点要素对象
//return pointFeature;
return vectorLayer;
}
// 创建一个数组来保存所有平行线的起点和终点坐标
const allParallelLinesCoordinates = [];
/**
* 在地图上绘制一条直线要素,并最终添加到地图上
* @param {Map} map - OpenLayers 地图实例
* @param {ol.Coordinate} coordinateA - 起点的坐标,格式为 [经度, 纬度]
* @param {ol.Coordinate} coordinateB - 终点的坐标,格式为 [经度, 纬度]
* @param {Style} style - 线的样式
* @returns {Feature} 返回创建的线要素对象
*/
function drawLineOnMap(map, coordinateA, coordinateB, style) {
console.log(`在地图上开始绘制线...coordinateA = ${coordinateA}, coordinateB = ${coordinateB}`);
// 1、创建一个线几何对象
const lineGeometry = new LineString([coordinateA, coordinateB]);
// 2、创建一个特征对象,包含几何对象和样式
const lineFeature = new Feature({
geometry: lineGeometry,
// 可以添加其他属性,例如线的名称等
name: 'Line'
});
// 3、设置该线的样式
const defaultStyle = new Style({
stroke: new Stroke({
color: '#ff2d51', // 线的颜色
width: 3 // 线的宽度
})
});
lineFeature.setStyle(!style ? defaultStyle : style);
// 4、创建一个矢量源,并将要素添加到矢量数据源中:
const vectorSource = new VectorSource({
features: [lineFeature]
});
// 5、创建一个矢量图层,并设置矢量源
const vectorLayer = new VectorLayer({
source: vectorSource
});
// 6、将矢量图层添加到地图
map.addLayer(vectorLayer);
// 最后返回线要素对象
//return lineFeature;
return vectorLayer;
}
/**
* 在AB线两侧绘制平行线
* @param {Map} map - OpenLayers 地图实例
* @param {ol.Coordinate} coordinateA - 起点的坐标,格式为 [经度, 纬度]
* @param {ol.Coordinate} coordinateB - 终点的坐标,格式为 [经度, 纬度]
* @param {number} lineCount - 平行线的数量
* @param {number} lineSpacing - 平行线之间的间隔(单位:米)
* @param {Style} style - 平行线的样式
*/
function drawLineAndParallelLines(map,
coordinateA, coordinateB,
lineCount, lineSpacing,
style
) {
// 创建AB线的几何对象
//const lineGeometry = new LineString([coordinateA, coordinateB]);
console.log(`进入平行线绘制: \n coordinateA = ${coordinateA}\n coordinateB = ${coordinateB}`)
// 计算AB线的方向向量
const dx = coordinateB[0] - coordinateA[0];
const dy = coordinateB[1] - coordinateA[1];
// 计算AB线的长度
const length = Math.sqrt(dx * dx + dy * dy);
// 计算AB线的方向向量单位向量
const unitVector = [dx / length, dy / length];
// 计算垂直于AB线方向向量的单位向量(用于平行线的偏移)
const perpendicularUnitVector = [-dy / length, dx / length];
// 创建平行线的矢量源
const parallelLinesSource = new VectorSource({
features: []
});
// 获取当前参考系下每米的单位转换系数
let metersPerUnit = map.getView().getProjection().getMetersPerUnit();
// 计算平行线的起点和终点偏移坐标辅助函数
function calculateOffsetCoordinate(coordinate, perpendicularUnitVector, offset) {
return [
coordinate[0] + perpendicularUnitVector[0] * offset,
coordinate[1] + perpendicularUnitVector[1] * offset
];
}
console.log(`每米对应的单位系数为: metersPerUnit = ${metersPerUnit}`)
// 计算并添加所有平行线
for (let i = -lineCount / 2; i <= lineCount / 2; i++) {
if (i === 0) continue; // 跳过中间的AB线
// 计算偏移量(将米转换成当前参考系的坐标值)
const offset = i * (lineSpacing / metersPerUnit);
console.log(`每米对应的单位系数为: offset = ${offset}`)
// 计算平行线的起点和终点偏移坐标
const startAOffset = calculateOffsetCoordinate(coordinateA, perpendicularUnitVector, offset);
const endBOffset = calculateOffsetCoordinate(coordinateB, perpendicularUnitVector, offset);
// 保存起点和终点坐标
allParallelLinesCoordinates.push([startAOffset, endBOffset]);
console.log(`当前循环:i = ${i}\n, 偏移起点 startAOffset=${startAOffset}\n, 偏移终点 endBOffset=${endBOffset}`)
// 创建平行线的几何对象,传入起点、终点值
const parallelLineGeometry = new LineString([startAOffset, endBOffset]);
// 创建平行线的特征对象
const parallelLineFeature = new Feature({
geometry: parallelLineGeometry
});
// 设置平行线的样式
parallelLineFeature.setStyle(style);
// 将平行线特征添加到矢量源中
parallelLinesSource.addFeature(parallelLineFeature);
}
// 创建平行线的矢量图层
const parallelLinesLayer = new VectorLayer({
source: parallelLinesSource
});
// 将平行线的矢量图层添加到地图
map.addLayer(parallelLinesLayer);
return parallelLinesLayer// 返回图层,方便后续删除等操作
}
///=========================需要绘制的矢量图层==========================
// 存储起点A、终点B 要素、AB直线
let startA, endB, lineString;
// 存储A、B坐标
let coordinateA, coordinateB
let parallelLinesLayer// 平行线图层
export function drawVector(map) {
// 单击地图,绘制起点A、终点B
// 1、绘制点A
map.on('singleclick', function (evt) {
// 如果点A不存在,才调用函数绘制点A
if (!startA) {
console.log(`单击绘制起点A测试:${evt.coordinate} ---》${fromLonLat(evt.coordinate)}`)
coordinateA = evt.coordinate
startA = drawPointOnMap(map, coordinateA, null);
} else if (!endB) {
console.log('单击绘制终点B测试')
coordinateB = evt.coordinate
// 新建一个特征要素,并传入要创建的图形(指定坐标上的点)
endB = drawPointOnMap(map, coordinateB, null);
// 绘制B点之后,绘制 AB直线
console.log(`单击绘制AB直线测试:coordinateA=${coordinateA} ---》coordinateB=${coordinateB}`)
drawLineOnMap(map, coordinateA, coordinateB, null)
// 先保存 AB 直线的两个端点坐标
allParallelLinesCoordinates.push([coordinateA, coordinateB])
// 绘制AB直线之后,绘制平行线
// 调试的时候,是无法使用安卓接口的!
// let lineCount = 10// 平行线数量
// let lineSpacing = 10// 平行线间距
let lineCount = Android.getLinesCount()//10// 平行线数量
let lineSpacing = Android.getLinesSpace()//10// 平行线间距
//Android.showToast(`js端:lineCount = ${lineCount}\n==>lineSpacing = ${lineSpacing}`);
// 绘制所有平行线,返回图层对象
parallelLinesLayer = drawLineAndParallelLines(map, coordinateA, coordinateB, lineCount, lineSpacing, null);
// 绘制完成,同时所有平行线坐标也记录完成,此时需要传给 Android 端保存
sendCoordinatesToKotlin(allParallelLinesCoordinates)
} else {
// 否则将:
// todo 也可以在此时 先删除旧的图形图层
map.removeLayer(startA)
map.removeLayer(endB)
map.removeLayer(parallelLinesLayer)
// 重置
startA = endB = null
// 并开始重新绘制点 A
coordinateA = evt.coordinate
startA = drawPointOnMap(map, coordinateA, null);
}
});
// 计算平行线的功能
function offsetLine(line, distance, side, lineCoordinates) {
const coords = line.getCoordinates();
const coord = coords[0];
const nextCoord = coords[1];
const dx = nextCoord[0] - coord[0];
const dy = nextCoord[1] - coord[1];
const length = Math.sqrt(dx * dx + dy * dy);
const offsetX = (dy / length) * distance;
const offsetY = -(dx / length) * distance;
const offsetCoords = coords.map((coord, index) => {
console.log('Line coordinates:', offsetX, offsetY, ol.proj.toLonLat([coord[0] + offsetX, coord[1] + offsetY]),);
if (side === 'left') {
return [coord[0] + offsetX, coord[1] + offsetY];
} else {
return [coord[0] - offsetX, coord[1] - offsetY];
}
});
console.log('Line coordinates:', offsetCoords, "start", ol.proj.toLonLat(offsetCoords[0]), "end", ol.proj.toLonLat(offsetCoords[1]));
lineCoordinates.push({start: ol.proj.toLonLat(offsetCoords[0]), end: ol.proj.toLonLat(offsetCoords[1])});
return new LineString(offsetCoords);
}
// 绘制线条的函数
function drawLines(lonA, latA, lonB, latB, numLines, distanceBetweenLines) {
const pointA = ol.proj.fromLonLat([lonA, latA]);
const pointB = ol.proj.fromLonLat([lonB, latB]);
const mainLine = new LineString([pointA, pointB]);
const vectorSource = new ol.source.Vector();
let lineCoordinates = [];
// 循环创建平行线
for (let i = 1; i <= numLines; i++) {
const offset = i * distanceBetweenLines;
// 偏移左侧线
const leftLine = offsetLine(mainLine, offset, 'left', lineCoordinates);
const leftFeature = new Feature(leftLine);
vectorSource.addFeature(leftFeature);
// 偏移右侧线
const rightLine = offsetLine(mainLine, offset, 'right', lineCoordinates);
const rightFeature = new Feature(rightLine);
vectorSource.addFeature(rightFeature);
}
// 主线
const mainFeature = new Feature(mainLine);
vectorSource.addFeature(mainFeature);
// 创建矢量图层
const vectorLayer = new ol.layer.Vector({
source: vectorSource,
style: new Style({
stroke: new Stroke({
color: '#ffcc33',
width: 2
})
})
});
map.setLayers([vectorLayer])
// 返回线条的坐标
return lineCoordinates;
}
// 初始化并调用 drawLines 函数
function initDrawLines() {
const latA = 39.9087;
const lonA = 116.3974;
const latB = 40;
const lonB = 110;
const numLines = 6;
const distanceBetweenLines = 10;
const coordinates = drawLines(lonA, latA, lonB, latB, numLines, distanceBetweenLines);
console.log('Line coordinates:', coordinates);
}
}
==========================绘制点、线矢量图形==========================
export {vectorLayer, startA, endB, lineString};
3.3 根目录核心js、html、css代码实现
1)index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>OpenLayers测试</title>
<link rel="stylesheet" href="node_modules/ol/ol.css">
</head>
<body>
<!-- 地图 Map 挂载在这个视图上 -->
<div id="map"></div>
<!-- 添加一组控制 旋转、平移、缩放 的自定义按钮进行测试-->
<div class="btns">
<button onclick="move2Top()">上移</button>
<button onclick="move2Bottom()">下移</button>
<button onclick="move2Left()">左移</button>
<button onclick="move2Right()">右移</button>
<button onclick="">顺时针旋转</button>
<button onclick="">逆时针旋转</button>
<button onclick="">放大</button>
<button onclick="">缩小</button>
<button onclick="">切换数据源(OSM或高德)</button>
</div>
<script type="module" src="main.js"></script>
</body>
</html>
2)main.js
import './style.css';
import {testTileLayer, baiduTileLayer, gaodeTileLayer} from './js/mapLayers.js';
import {vectorLayer, startA, endB, lineString} from './js/vectorDrawing.js';
import {quickInteractions, controls} from './js/mapInteractions.js';
import {setupButtonEvents} from './js/buttonEvents.js';
import {drawVector} from './js/vectorDrawing.js';
import {callAndroidMethods, sendCoordinatesToKotlin} from './js/helper/externalAPI.js';
import {Map, View} from 'ol';
import {fromLonLat} from "ol/proj";
import {Tile as TileLayer} from "ol/layer";
import {OSM} from "ol/source";
///==========================地图对象配置==========================
// 定义深圳的经纬度常量
const SHENZHEN_COORDINATE = [114.057868, 22.543099];
const map = new Map({
/// 1、图层:
// - 瓦片地图,用于显示地图
// - 矢量图层,用于绘制矢量图形
layers: [
// testTileLayer,// 测试的瓦片地图图层,一般需要科学上网...
// baiduTileLayer,// 百度// todo 暂时测试不通过:无法使用!
gaodeTileLayer,// 高德
//vectorLayer,// 要绘制的矢量图图层,在下面动态添加
],
/// 2、挂载在 id 为 ‘map’ 的视图上
target: 'map',
/// 3、视图对象,用于控制地图的中心、缩放级别和投影等内容
// - 中心点经纬度,需要转换成投影坐标值
// - 初始缩放级别
// - 投影体系:默认是 EPSG:3857 ,如果设置为 EPSG:4326 (经纬度体系) 就不需要 fromLonLat 进行转换
view: new View({
// projection: "EPSG:3857",// 此处依然使用默认值,一般使用默认值,就需要 fromLonLat 函数转换(否则可能导致地图无法显示)
// center: fromLonLat(SHENZHEN_COORDINATE), // 深圳的经纬度,此处使用fromLonLat将 经纬度值 转换为 地图投影坐标
projection: "EPSG:4326",// 此处(经纬度体系)
center: SHENZHEN_COORDINATE, // 深圳的经纬度,需要使用 "EPSG:4326" 经纬度参考系
zoom: 18,// 因为涉及米单位间距,地图太小了无法进行测试(10米在地图上是非常小的)
}),
// todo 警告:我在使用的时候,发现电脑上两个图标重叠或者没有重置旋转按钮(重置旋转和全屏图标),但是在openlayers官网示例并不重叠!
// 添加全屏控制(如果地图上没有按钮,则表示您的浏览器不支持全屏 API)
//controls: controls,
// 添加【拖动、旋转、缩放】快捷交互(电脑上按住 shift + 鼠标操作)
interactions: quickInteractions,
});
// 绘制矢量图形
drawVector(map);
// 按钮事件处理
setupButtonEvents(map);
// 调用Android 代码测试
// callAndroidMethods();
style.css
@import "node_modules/ol/ol.css";
html, body {
margin: 0;
width: 100%;
height: 100vh;
}
#map {
position: absolute;
top: 0;
bottom: 0;
width: 100%;
/* todo 我测试的过程中,给定确定的高度(不能是比例之类的),否则在安卓上高度拿不到...*/
height: 600px;/* 100%!important;bug 提示:不设置地图高度,可能会导致在安卓上无法显示!*/
}
.btns {
position: fixed;
display: flex; /* 使用 Flexbox 布局 */
flex-direction: column; /* 设置为列布局,即竖向排列 */
align-items: center; /* 居中对齐 */
padding: 20px; /* 增加一些内边距 */
}
.btns button {
margin: 5px; /* 按钮之间的间距 */
padding: 10px; /* 按钮内部的填充 */
cursor: pointer; /* 鼠标悬停时显示指针 */
}
package.json
{
"name": "my-app",
"version": "1.0.0",
"scripts": {
"start": "vite",
"build": "vite build",
"serve": "vite preview"
},
"devDependencies": {
"vite": "^5.4.10"
},
"dependencies": {
"ol": "10.2.1"
}
}
最后: