鸿蒙宝藏案例详解:共享单车"丝滑"骑行体验的代码实现 🚲💻
大家好!上次分享了鸿蒙那个超棒的共享单车体验案例,很多朋友留言说想看代码细节。没问题!这就带大家深入代码层,看看那些"丝滑"的体验(扫码直达、实时状态窗、路径规划)到底是怎么敲出来的。官方文档有时像藏宝图,代码才是真金白银!
核心目标再强调: 用HarmonyOS的
Scan Kit
(扫码直达)、Map Kit
(找车导航)、Live View Kit
(实况窗)三大能力,把扫码->解锁->骑行->还车->支付的流程做到极简、实时、无感。
🎯 模块一:扫码直达解锁页 (Scan Kit)
目标: 用户在任何地方扫码,直接跳转到该单车的解锁确认页,跳过打开App、找入口的步骤。
关键代码详解 (TypeScript/ArkTS)
typescript
typescript
复制
// 1. 导入关键模块
import scanBarcode from '@ohos.abilityAccessCtrl'; // Scan Kit核心模块
import { router } from '@kit.ArkUI'; // 页面路由模块
import { BusinessError } from '@kit.BasicServicesKit'; // 错误处理
// 2. 扫码工具类 (ScanUtil.ts)
export class ScanUtil {
public static scan(obj: Object): void {
// 3. 配置扫码选项:支持所有类型码(ALL)和一维码(ONE_D_CODE),允许多码识别,允许从相册选图
let options: scanBarcode.ScanOptions = {
scanTypes: [scanBarcode.ScanType.ALL, scanBarcode.ScanType.ONE_D_CODE],
enableMultiMode: true,
enableAlbum: true
};
try {
// 4. 启动扫码并等待结果 (异步Promise)
scanBarcode.startScanForResult(getContext(obj), options)
.then((result: scanBarcode.ScanResult) => {
console.info('扫码结果:', JSON.stringify(result));
// 5. 关键逻辑:判断扫码类型 (假设CyclingConstants.SCAN_TYPE代表单车码)
if (result.scanType === CyclingConstants.SCAN_TYPE) {
// 6. 设置应用状态:等待解锁 (AppStorage是鸿蒙的状态管理)
AppStorage.setOrCreate(CyclingConstants.CYCLING_STATUS, CyclingStatus.WAITING_UNLOCK);
// 7. 核心跳转!直接路由到解锁确认页 'pages/ConfirmUnlock'
router.pushUrl({ url: 'pages/ConfirmUnlock' });
// 通常这里会把扫码得到的数据(如单车ID)通过params传递给ConfirmUnlock页面
}
})
.catch((error: BusinessError) => {
console.error('扫码出错:', JSON.stringify(error));
// 处理错误:如提示用户、重试等
});
} catch (error) {
console.error('启动扫码失败:', JSON.stringify(error));
}
}
}
代码解析 & 关键点:
-
权限申请 (
module.json5
): 扫码必须的相机权限!必须在配置文件声明:jsonjson 复制 "requestPermissions": [ { "name": "ohos.permission.CAMERA", "reason": "用于扫描共享单车二维码", // 给用户看的理由 "usedScene": { "abilities": ["EntryAbility"], // 在哪个Ability申请 "when": "always" // 使用时机 } } ],
-
ScanOptions
配置灵活:scanTypes
: 指定识别的码类型,非常灵活。enableMultiMode
: 是否一次扫多个码(共享单车通常不需要,关掉更快)。enableAlbum
: 是否允许从相册选择二维码图片(重要!用户可能截图扫码)。
-
startScanForResult
: 这是启动扫码的核心API,返回一个Promise
。.then()
里处理成功结果,.catch()
处理失败。 -
结果处理 (
result
):result.scanType
: 识别出的码类型(二维码?条形码?)。result.value
: 扫码得到的数据字符串(通常包含单车唯一ID、解锁指令等)。这个例子简化了,实际业务中这里会解析result.value
获取单车信息!
-
状态管理 (
AppStorage
): 鸿蒙提供的应用级状态管理。这里设置CYCLING_STATUS = WAITING_UNLOCK
,告诉应用"用户扫到码了,等待确认解锁"。这个状态会被解锁页面使用。 -
router.pushUrl
: 实现"直达"的关键! 直接路由导航到解锁确认页pages/ConfirmUnlock
。用户瞬间从扫码界面跳到了解锁按钮面前,省去所有中间步骤。通常会把单车ID等信息通过params
传递过去:router.pushUrl({ url: 'pages/ConfirmUnlock', params: { bikeId: parsedBikeId } })
。
调用时机: 在你的首页(Index
)、共享单车功能页(BikePage
),甚至一个桌面万能卡片(Card
)的按钮点击事件里,调用ScanUtil.scan(this)
即可触发扫码。
🗺️ 模块二:智能找车与步行导航 (Map Kit)
目标: 在"找车"页面,显示用户位置、车辆位置,并绘制步行路线。
关键代码详解 (地图初始化、定位、路径规划与绘制)
typescript
typescript
复制
// 1. 导入关键模块
import { MapComponent, mapCommon, map, navi } from '@kit.MapKit'; // 地图核心
import geoLocationManager from '@ohos.geoLocationManager'; // 定位管理
import abilityAccessCtrl from '@ohos.abilityAccessCtrl'; // 权限申请
import { BusinessError } from '@kit.BasicServicesKit';
// 2. 在找车页面 (FindBikePage.ets)
@Entry
@Component
struct FindBikePage {
// ... 其他状态变量 ...
private mapController?: map.MapComponentController; // 地图控制器
private mapPolyline?: map.MapPolyline; // 用于绘制路线的线对象
private myPosition: mapCommon.LatLng = { latitude: 0, longitude: 0 }; // 用户位置
aboutToAppear(): void {
// 3. 初始化地图回调
this.callback = async (err, mapController) => {
if (!err) {
this.mapController = mapController;
this.mapController.on('mapLoad', async () => {
// 4. 检查并申请定位权限
const hasPerm = await this.checkLocationPermissions();
if (hasPerm) {
this.enableMyLocation(); // 开启定位并获取位置
}
});
}
};
}
// 5. 检查定位权限
private async checkLocationPermissions(): Promise<boolean> {
const atManager = abilityAccessCtrl.createAtManager();
try {
const permissions = [
'ohos.permission.LOCATION',
'ohos.permission.APPROXIMATELY_LOCATION'
];
const grantStatus = await atManager.checkAccessToken(
abilityAccessCtrl.AccessTokenID.BASE,
permissions
);
return grantStatus === abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED;
} catch (error) {
console.error('检查权限出错', error);
return false;
}
}
// 6. 申请定位权限
private requestPermissions(): void {
const atManager = abilityAccessCtrl.createAtManager();
atManager.requestPermissionsFromUser(
getContext(this) as common.UIAbilityContext,
['ohos.permission.LOCATION', 'ohos.permission.APPROXIMATELY_LOCATION']
).then(() => {
this.enableMyLocation(); // 权限获取成功,开启定位
}).catch((err: BusinessError) => {
console.error('申请权限失败', err.code, err.message);
});
}
// 7. 开启定位并获取当前位置
private enableMyLocation(): void {
if (!this.mapController) return;
// 7.1 设置地图显示我的位置
this.mapController.setMyLocationEnabled(true);
this.mapController.setMyLocationControlsEnabled(true); // 显示定位按钮
// 7.2 配置定位请求参数 (高精度、首次定位)
let requestInfo: geoLocationManager.CurrentLocationRequest = {
priority: geoLocationManager.LocationRequestPriority.FIRST_FIX,
scenario: geoLocationManager.LocationRequestScenario.NAVIGATION,
maxAccuracy: 50 // 精度要求(米)
};
// 7.3 获取当前位置
geoLocationManager.getCurrentLocation(requestInfo)
.then(async (location) => {
console.info('获取到位置:', location.latitude, location.longitude);
// 7.4 坐标转换 (WGS84 -> 国内常用的GCJ02)
let mapPosition: mapCommon.LatLng = await map.convertCoordinate(
mapCommon.CoordinateType.WGS84,
mapCommon.CoordinateType.GCJ02,
{ latitude: location.latitude, longitude: location.longitude }
);
// 7.5 存储用户位置 & 移动地图视角
this.myPosition = mapPosition;
AppStorage.setOrCreate('userLat', mapPosition.latitude);
AppStorage.setOrCreate('userLon', mapPosition.longitude);
let cameraUpdate = map.newCameraPosition({
target: mapPosition,
zoom: 16 // 放大到合适级别
});
this.mapController?.animateCamera(cameraUpdate, 1000); // 1秒动画移动到用户位置
})
.catch((err: BusinessError) => {
console.error('获取位置失败', err.code, err.message);
});
}
// 8. 监听地图点击 (用户点选单车位置)
private setupMapListeners(): void {
this.mapController?.on('mapClick', async (clickedPosition: mapCommon.LatLng) => {
// 8.1 清除旧标记和路线
this.mapController?.clear();
this.mapPolyline?.remove();
// 8.2 在点击位置添加一个标记 (Marker)
this.marker = await MapUtil.addMarker(clickedPosition, this.mapController);
// 8.3 关键!发起步行路径规划 (从用户位置this.myPosition 到 点击位置clickedPosition)
const walkingRoutes = await MapUtil.walkingRoutes(clickedPosition, this.myPosition);
if (walkingRoutes && walkingRoutes.routes.length > 0) {
// 8.4 绘制规划好的步行路线
await MapUtil.paintRoute(walkingRoutes, this.mapPolyline, this.mapController);
}
});
}
build() {
Column() {
// 9. 集成地图组件 (核心UI)
MapComponent({
mapOptions: { ... }, // 地图初始配置 (中心点、缩放级别等)
mapCallback: this.callback // 地图加载完成的回调
})
.onClick(() => {
this.setupMapListeners(); // 通常在地图加载后设置监听
})
.width('100%')
.height('100%')
}
}
}
// 10. 路径规划工具类 (MapUtil.ts)
export class MapUtil {
// 10.1 步行路径规划
public static async walkingRoutes(
destination: mapCommon.LatLng,
origin?: mapCommon.LatLng
): Promise<navi.RouteResult | undefined> {
if (!origin) return undefined;
let params: navi.RouteParams = {
origins: [origin], // 起点数组 (这里一个)
destination: destination, // 终点
type: navi.RouteType.WALKING, // 步行模式
language: 'zh_CN' // 中文结果
};
try {
const result = await navi.getWalkingRoutes(params); // 调用Map Kit API
console.info('步行路线规划成功', JSON.stringify(result));
return result;
} catch (err) {
console.error('步行路线规划失败', JSON.stringify(err));
return undefined;
}
}
// 10.2 绘制路线到地图
public static async paintRoute(
routeResult: navi.RouteResult,
mapPolyline: map.MapPolyline | undefined,
mapController?: map.MapComponentController
) {
if (!mapController || !routeResult.routes[0]?.overviewPolyline) return;
// 清除旧线
mapPolyline?.remove();
// 配置新线的样式 (蓝色,20像素宽)
let polylineOption: mapCommon.MapPolylineOptions = {
points: routeResult.routes[0].overviewPolyline, // 路线坐标点数组
clickable: true,
width: 20,
color: 0xFF2970FF, // ARGB 蓝色
zIndex: 10
};
// 添加折线到地图并保存引用
mapPolyline = await mapController.addPolyline(polylineOption);
return mapPolyline;
}
// ... (addMarker 方法类似) ...
}
代码解析 & 关键点:
-
权限 (
module.json5
): 定位权限同样必须声明:jsonjson 复制 "requestPermissions": [ { "name": "ohos.permission.LOCATION", "reason": "用于查找附近的共享单车和导航" }, { "name": "ohos.permission.APPROXIMATELY_LOCATION", "reason": "用于更精准的找车定位" } ],
-
MapComponent
: 地图的UI组件。mapCallback
在地图加载完成 后触发,此时才能安全地获取mapController
进行操作。 -
定位流程 (
enableMyLocation
):setMyLocationEnabled(true)
: 让地图显示用户位置蓝点。getCurrentLocation
: 获取一次 精确位置。对于持续追踪,需用on('locationChange')
监听。- 坐标转换 (
convertCoordinate
): 非常重要! 设备GPS返回的是WGS84坐标,国内地图服务(如GCJ02)需要转换才能准确显示。
-
路径规划 (
getWalkingRoutes
):- 调用
navi.getWalkingRoutes(params)
是核心。传入起点(origins
)、终点(destination
)、类型(WALKING
)。 - 返回的
RouteResult
包含路线信息,其中overviewPolyline
是一串压缩过的经纬度点,用于绘制路线。
- 调用
-
绘制路线 (
addPolyline
):- 使用
mapController.addPolyline(options)
绘制折线。 options.points
传入路线规划得到的坐标点数组 (overviewPolyline
需要先解码,示例代码假设MapUtil.walkingRoutes
内部或返回结果已处理)。- 通过
width
,color
等属性定制路线外观。
- 使用
-
交互流程: 用户点击地图 -> 获取点击点坐标 -> 清除旧数据 -> 添加新Marker -> 规划并绘制到该Marker的步行路线。
✨ 模块三:实况窗展示骑行状态 (Live View Kit)
目标: 解锁后,在状态栏(胶囊)、通知中心、锁屏实时显示骑行状态/时长/费用;还车后变待支付;支付后结束。
关键代码详解 (创建、更新、销毁实况窗)
kotlin
typescript
复制
// 1. 导入关键模块
import liveViewManager, { LiveViewDataBuilder, TextLayoutBuilder, TextCapsuleBuilder, LiveNotification, LiveViewContext } from '@kit.LiveViewKit';
import { BusinessError } from '@kit.BasicServicesKit';
import wantAgent from '@ohos.app.ability.wantAgent'; // 用于定义点击动作
// 2. 实况窗控制类 (LiveViewController.ts)
export class LiveViewController {
private liveViewData?: liveViewManager.LiveViewData; // 当前实况窗数据
private liveNotification?: LiveNotification; // 实况窗通知对象
// 3. 创建并显示实况窗 (在用户点击"解锁"后调用)
public async startLiveView(context: LiveViewContext): Promise<liveViewManager.LiveViewResult> {
// 3.1 构建默认的实况窗数据 (骑行中状态)
this.liveViewData = await this.buildDefaultView(context);
// 3.2 创建LiveNotification对象 (关联环境信息,如业务类型'RENT')
let env: liveViewManager.LiveViewEnvironment = { id: 0, event: 'RENT' };
this.liveNotification = LiveNotification.from(context, env);
// 3.3 创建并显示实况窗!
return await this.liveNotification.create(this.liveViewData);
}
// 4. 构建默认骑行中状态的实况窗数据
private static async buildDefaultView(context: LiveViewContext): Promise<liveViewManager.LiveViewData> {
// 4.1 构建展开态卡片布局 (锁屏/通知中心看到的卡片)
const layoutData = new TextLayoutBuilder()
.setTitle('骑行中') // 卡片标题
.setContent('已骑行 0 分钟') // 卡片内容 (初始0分钟)
.setDescPic('bike_icon.png'); // 卡片右侧图标
// 4.2 构建胶囊态 (状态栏看到的小胶囊)
const capsule = new TextCapsuleBuilder()
.setIcon('bike_small.png') // 胶囊图标
.setBackgroundColor('#FF00FF00') // 胶囊背景色 (绿色)
.setTitle('骑行中'); // 胶囊文字
// 4.3 构建点击动作 (点击实况窗跳转回App的骑行页面)
const wantAgentInfo: wantAgent.WantAgentInfo = {
wants: [
{
bundleName: context.bundleName,
abilityName: 'EntryAbility',
parameters: { route: 'pages/RidingPage' } // 跳转到骑行页
}
],
operationType: wantAgent.OperationType.START_ABILITY,
requestCode: 0
};
const wantAgentObj = await wantAgent.getWantAgent(wantAgentInfo);
// 4.4 构建完整的LiveViewData
const liveViewData = new LiveViewDataBuilder()
.setTitle('骑行中') // 主标题
.setContentText(['已骑行 0 分钟']) // 内容文本数组 (可多行)
.setContentColor('#FFFFFFFF') // 内容文字颜色 (白色)
.setLayoutData(layoutData) // 设置卡片布局
.setCapsule(capsule) // 设置胶囊样式
.setWant(wantAgentObj) // 设置点击动作
// (可选) 配置锁屏沉浸态扩展Ability (见后面)
.setLiveViewLockScreenAbilityName('LiveViewLockScreenExtAbility')
.setLiveViewLockScreenPicture('bike_lock_icon.png')
.build(); // 构建完成
return liveViewData;
}
// 5. 更新实况窗状态 (骑行中 -> 待支付 -> 支付完成)
public async updateLiveView(status: number, context: LiveViewContext): Promise<liveViewManager.LiveViewResult> {
if (!this.liveViewData || !this.liveNotification) {
console.error('实况窗未创建或数据为空');
return { code: -1 };
}
switch (status) {
case CyclingStatus.RIDING: // 骑行中 (更新计时)
// ... 更新 this.liveViewData 的计时文本 (e.g., '已骑行 5 分钟') ...
return await this.liveNotification.update(this.liveViewData);
case CyclingStatus.WAITING_PAYMENT: // 还车成功,待支付
// 5.1 更新标题、内容、胶囊文字
this.liveViewData.primary.title = '待支付';
this.liveViewData.primary.content = [{ text: '骑行结束,点击支付', textColor: '#FFFFFFFF' }];
this.liveViewData.capsule.title = '待支付';
// 5.2 更新点击动作 (点击跳转到支付页)
this.liveViewData.primary.clickAction = await this.buildWantAgent(context, 'pages/PaymentPage');
// 5.3 更新卡片布局
this.liveViewData.primary.layoutData = new TextLayoutBuilder()
.setTitle('待支付')
.setContent('费用:¥2.50')
.setDescPic('payment_icon.png');
return await this.liveNotification.update(this.liveViewData);
case CyclingStatus.PAYMENT_COMPLETED: // 支付完成
// 5.4 更新为最终状态
this.liveViewData.primary.title = '支付成功';
this.liveViewData.primary.content = [{ text: '行程已完成,感谢使用', textColor: '#FFFFFFFF' }];
this.liveViewData.capsule.title = '完成';
// 5.5 关键!停止实况窗 (显示最终状态几秒后消失)
return await this.liveNotification.stop(this.liveViewData);
default:
return { code: -1 };
}
}
// ... (buildWantAgent 辅助方法) ...
}
// 6. 锁屏沉浸态实况窗扩展Ability (LiveViewLockScreenExtAbility.ets)
import { LiveViewLockScreenExtensionAbility, UIExtensionContentSession } from '@kit.LiveViewKit';
import hilog from '@ohos.hilog';
export default class LiveViewLockScreenExtAbility extends LiveViewLockScreenExtensionAbility {
onSessionCreate(want: Want, session: UIExtensionContentSession) {
hilog.info(0x0000, 'LiveViewLock', '锁屏扩展Ability创建会话');
// 6.1 加载自定义的锁屏实况窗UI页面
session.loadContent('pages/LiveViewLockScreenPage'); // 这个页面你用ArkUI自己设计!
}
// ... (其他生命周期方法 onForeground, onBackground, onDestroy) ...
}
代码解析 & 关键点:
-
LiveViewDataBuilder
: 构建实况窗数据的核心工具。它定义了:- 主信息 (
primary
): 标题、内容文本/颜色、点击动作(WantAgent
)、卡片布局(LayoutData
)、锁屏扩展能力名/参数/图片。 - 胶囊态 (
capsule
): 状态栏显示的图标、背景色、文字。 - 其他: 显示时长(
keepTime
)、是否持久化等。
- 主信息 (
-
状态管理: 实况窗内容不是静态的!
updateLiveView
方法根据业务状态 (RIDING
,WAITING_PAYMENT
,PAYMENT_COMPLETED
) 动态更新 liveViewData
的各个部分,然后调用update()
或stop()
刷新界面。 -
WantAgent
: 实现点击交互的关键! 定义了用户点击实况窗(胶囊或卡片)后要执行的动作。最常见的就是跳转回App的特定页面(如骑行页、支付页)。wantAgent
模块用于构建这个意图。 -
LiveNotification
: 负责实况窗的生命周期管理 (create
,update
,stop
)。.from(context, env)
将实况窗与特定的业务环境(env
)关联起来。 -
沉浸态锁屏实况窗 (高级):
-
在
LiveViewDataBuilder
中配置setLiveViewLockScreenAbilityName
和setLiveViewLockScreenPicture
。 -
实现一个继承自
LiveViewLockScreenExtensionAbility
的Ability。 -
在
onSessionCreate
方法中,使用session.loadContent('你的自定义UI页面路径')
加载你用ArkUI编写的自定义锁屏卡片界面。这让你可以展示比默认模板更丰富的信息(比如地图缩略图、更详细的费用明细)。 -
声明扩展Ability (
module.json5
):jsonjson 复制 "extensionAbilities": [ { "name": "LiveViewLockScreenExtAbility", "type": "liveViewLockScreen", // 类型必须为liveViewLockScreen "srcEntry": "./ets/entryability/LiveViewLockScreenExtAbility.ets", "exported": true // 允许系统访问 } ],
-
-
服务开通: 使用实况窗能力前 ,需要在
AppGallery Connect
后台为你的应用开通Live View Kit
服务权益。
📌 总结与思考
把这三块核心代码串起来,就构成了那个"丝滑"骑行体验的骨架:
-
ScanUtil.scan()
被调用 -> 扫码成功 ->router.pushUrl
直达解锁页。 - 用户点击解锁 -> 调用
LiveViewController.startLiveView()
创建实况窗 (显示骑行中)。 - 骑行中