Tích hợp bản đồ Việt Nam vào ứng dụng Flutter (Android, iOS) với maplibre_gl

Trả lời nhanh: dùng thư viện maplibre_gl với styleString: 'https://map.vapp.vn/style.json'. Bản đồ hiện ngay, không cần khóa và không tính lượt. Chỉ khi ứng dụng gọi API, ví dụ lấy tên địa điểm của điểm ghim, mới cần khóa API: tạo khóa miễn phí tại trang nhà phát triển.

Mọi lớp, tham số và hàm maplibre_gl trong mã Dart dưới đây đã được đối chiếu với mã nguồn bản 0.27.1 ngày 2026-10-19. Mã chưa được biên dịch thành ứng dụng thật: nếu gặp lỗi, hãy báo qua support@erpfit.com để chúng tôi sửa.

1. Cái gì cần khóa, cái gì tính lượt

ViệcGọi tớiCần khóa?Tính vào hạn mức?
Hiển thị bản đồhttps://map.vapp.vn/style.json (tự tải tile và font)KhôngKhông
Ghim địa điểm, vị trí hiện tạixử lý trên máyKhôngKhông
Tên địa điểm tại một tọa độGET /api/v1/reverse?lat=…&lng=…Có (x-api-key)1 lượt mỗi lần gọi
Tìm địa điểm theo tênGET /api/v1/autocomplete?q=…Có (x-api-key)1 lượt mỗi lần gọi

Bản đồ là vector tile, nên chỉ cần một đường dẫn style; không cần cấu hình nguồn tile riêng.

2. Cài đặt

flutter pub add maplibre_gl http

Android (android/app/src/main/AndroidManifest.xml, trong <manifest>):

<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>

iOS (ios/Runner/Info.plist):

<key>NSLocationWhenInUseUsageDescription</key>
<string>Ứng dụng dùng vị trí để hiện bạn đang ở đâu trên bản đồ.</string>

3. Lấy khóa API và giữ khóa ngoài mã nguồn

Đăng nhập trang nhà phát triển bằng Google, bấm Tạo khóa API và sao chép khóa (khóa chỉ hiện một lần). Không viết khóa thẳng vào mã và không đưa khóa lên GitHub; truyền khóa lúc chạy:

flutter run --dart-define=VAPP_MAP_KEY=vsk_xxxxxxxx...
const vappMapKey = String.fromEnvironment('VAPP_MAP_KEY');

Khóa nằm trong ứng dụng đã build, nên người khác vẫn có thể lấy được. Vì vậy khóa có giới hạn theo phút và theo ngày. Nếu khóa bị lộ, thu hồi nó trên trang nhà phát triển và tạo khóa mới.

4. Hiển thị bản đồ, chạm để ghim, vị trí hiện tại

import 'dart:math' show Point;
import 'package:flutter/material.dart';
import 'package:maplibre_gl/maplibre_gl.dart';

class BanDoKyNiem extends StatefulWidget {
  const BanDoKyNiem({super.key});
  @override
  State<BanDoKyNiem> createState() => _BanDoKyNiemState();
}

class _BanDoKyNiemState extends State<BanDoKyNiem> {
  MapLibreMapController? _map;

  @override
  Widget build(BuildContext context) {
    return Stack(children: [
      MapLibreMap(
        styleString: 'https://map.vapp.vn/style.json',
        initialCameraPosition: const CameraPosition(
          target: LatLng(16.0, 106.0), // giữa Việt Nam
          zoom: 4.8,
        ),
        myLocationEnabled: true,                          // chấm xanh vị trí hiện tại
        myLocationTrackingMode: MyLocationTrackingMode.none,
        onMapCreated: (c) => _map = c,
        onMapClick: _onTap,                               // chạm để ghim
      ),
      // Ghi nguồn: điều kiện của gói miễn phí — vui lòng giữ.
      const Positioned(
        left: 8, bottom: 8,
        child: DecoratedBox(
          decoration: BoxDecoration(color: Color(0xCCFFFFFF)),
          child: Padding(
            padding: EdgeInsets.symmetric(horizontal: 6, vertical: 2),
            child: Text('© map.vapp.vn · © OpenStreetMap', style: TextStyle(fontSize: 11)),
          ),
        ),
      ),
    ]);
  }

  Future<void> _onTap(Point<double> _, LatLng at) async {
    await _map?.addCircle(CircleOptions(
      geometry: at,
      circleRadius: 8,
      circleColor: '#E11D48',
      circleStrokeColor: '#FFFFFF',
      circleStrokeWidth: 2,
    ));
    final name = await placeName(at);        // tùy chọn, tốn 1 lượt
    if (!mounted) return;
    ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(name ?? 'Đã ghim')));
  }
}

Để hiện các địa điểm đã lưu khi mở ứng dụng, đọc chúng từ bộ nhớ của bạn (ví dụ sqflite hoặc shared_preferences) rồi gọi addCircle cho từng điểm, sau khi style đã tải xong: dùng tham số onStyleLoadedCallback của MapLibreMap. Các điểm này không gọi API, nên không tính lượt.

5. Tên địa điểm của điểm ghim (reverse geocode)

import 'dart:convert';
import 'package:http/http.dart' as http;

Future<String?> placeName(LatLng at) async {
  final uri = Uri.https('map.vapp.vn', '/api/v1/reverse', {
    'lat': at.latitude.toStringAsFixed(6),
    'lng': at.longitude.toStringAsFixed(6),
  });
  final res = await http.get(uri, headers: {'x-api-key': vappMapKey});
  if (res.statusCode != 200) return null;   // 429 = vượt hạn mức, 401 = khóa hết hạn hoặc đã thu hồi
  final r = jsonDecode(utf8.decode(res.bodyBytes))['result'];
  return r?['name'] as String?;
}

6. Mã lỗi

MãÝ nghĩaNên làm
401khóa hết hạn, đã thu hồi hoặc không đúngtạo khóa mới trên trang nhà phát triển
429vượt giới hạn theo phút, theo ngày hoặc theo thángđợi theo header retry-after; xem hạn mức đã dùng trên trang nhà phát triển

7. Hỗ trợ

map.vapp.vn đang ở giai đoạn alpha: chúng tôi vận hành với nỗ lực tốt nhất và rất mong bạn góp ý. Viết tới support@erpfit.com. Các hướng dẫn khác: Hướng dẫn.