1. 事前準備
本程式碼研究室將教導您如何搭配 SwiftUI 使用 Maps SDK for iOS。
必要條件
- 具備 Swift 基本知識
- 熟悉 SwiftUI 基本概念
學習內容
- 啟用並使用 Maps SDK for iOS,透過 SwiftUI 將 Google 地圖新增至 iOS 應用程式。
- 在地圖中加入標記。
- 在 SwiftUI 和
GMSMapView
物件之間傳遞狀態。
軟硬體需求
- Xcode 11.0 以上版本
- 已啟用計費功能的 Google 帳戶
- Maps SDK for iOS
- 迦太基
2. 做好準備
在下一個啟用步驟中,請啟用 Maps SDK for iOS。
設定 Google 地圖平台
如果您尚未建立 Google Cloud Platform 帳戶,以及啟用計費功能的專案,請參閱「開始使用 Google 地圖平台」指南,建立帳單帳戶和專案。
- 在 Cloud 控制台中,按一下專案下拉式選單,然後選取要用於本程式碼研究室的專案。
- 在 Google Cloud Marketplace 中,啟用本程式碼研究室所需的 Google 地圖平台 API 和 SDK。如要瞭解如何操作,請觀看這部影片或參閱這份說明文件。
- 在 Cloud Console 的「憑證」頁面中產生 API 金鑰。你可以按照這部影片或這份文件中的步驟操作。所有 Google 地圖平台要求都需要 API 金鑰。
3. 下載範例程式碼
為協助您盡快上手,我們提供一些範例程式碼,方便您跟著本程式碼研究室的說明操作。歡迎直接前往解決方案,但如果您想按照所有步驟自行建構,請繼續閱讀。
- 如果已安裝
git
,請複製存放區。
git clone https://github.com/googlecodelabs/maps-ios-swiftui.git
或者,您也可以點選下列按鈕下載原始碼。
- 取得驗證碼後,在終端機中
cd
到starter/GoogleMapsSwiftUI
目錄。 - 執行
carthage update --platform iOS
,下載 Maps SDK for iOS - 最後,在 Xcode 中開啟
GoogleMapsSwiftUI.xcodeproj
檔案
4. 程式碼總覽
在您下載的初始專案中,系統已為您提供並實作下列類別:
AppDelegate
- 應用程式的UIApplicationDelegate
。您將在此處初始化 Maps SDK for iOS。City
- 代表城市的結構體 (包含城市名稱和座標)。MapViewController
- 範圍縮小的 UIKitUIViewController
,內含 Google 地圖 (GMSMapView)SceneDelegate
- 應用程式的UIWindowSceneDelegate
,ContentView
是從中例項化。
此外,下列類別已部分實作,您將在本程式碼研究室結束前完成這些類別:
ContentView
- 包含應用程式的頂層 SwiftUI 檢視區塊。MapViewControllerBridge
- 將 UIKit 檢視區塊橋接至 SwiftUI 檢視區塊的類別。具體來說,這個類別可讓 SwiftUI 存取MapViewController
。
5. SwiftUI 與 UIKit
iOS 13 推出了 SwiftUI,做為開發 iOS 應用程式時,取代 UIKit 的 UI 架構。與前身 UIKit 相比,SwiftUI 有許多優點。例如:
- 狀態變更時,檢視畫面會自動更新。使用名為「狀態」的物件,系統會在物件所含基礎值有任何變更時,自動更新 UI。
- 即時預覽功能可加快開發速度。即時預覽功能可減少將程式碼建構及部署至模擬器的需求,因為您可以在 Xcode 上輕鬆預覽 SwiftUI 檢視畫面,查看視覺變化。
- 可靠資料來源位於 Swift 中。SwiftUI 中的所有檢視區塊都是以 Swift 宣告,因此不再需要使用介面建構器。
- 可與 UIKit 互通。與 UIKit 的互通性可確保現有應用程式能逐步使用 SwiftUI 和現有檢視區塊。此外,您仍可在 SwiftUI 中使用尚不支援 SwiftUI 的程式庫,例如 Maps SDK for iOS。
但也有一些缺點:
- SwiftUI 僅適用於 iOS 13 以上版本。
- 無法在 Xcode 預覽畫面中檢查檢視區塊階層。
SwiftUI 狀態和資料流程
SwiftUI 提供全新的 UI 建立方式,採用宣告式方法,您只要告訴 SwiftUI 檢視區塊的外觀和所有不同狀態,系統就會完成其餘工作。每當基礎狀態因事件或使用者動作而變更時,SwiftUI 就會更新檢視區塊。這種設計通常稱為「單向資料流」。本程式碼研究室不會深入探討這項設計的具體細節,但建議您閱讀 Apple 的狀態和資料流說明文件,瞭解這項設計的運作方式。
使用 UIViewRepresentable 或 UIViewControllerRepresentable 橋接 UIKit 和 SwiftUI
由於 Maps SDK for iOS 是以 UIKit 為基礎建構,且未提供與 SwiftUI 相容的檢視區塊,因此在 SwiftUI 中使用時,必須符合 UIViewRepresentable
或 UIViewControllerRepresentable
。這些通訊協定可讓 SwiftUI 分別納入以 UIKit 建構的 UIView
和 UIViewController
。雖然您可以使用任一通訊協定,在 SwiftUI 檢視區塊中新增 Google 地圖,但我們會在下一個步驟中,瞭解如何使用 UIViewControllerRepresentable
納入含有地圖的 UIViewController
。
6. 新增地圖
在本節中,您將在 SwiftUI 檢視區塊中加入 Google 地圖。
新增 API 金鑰
您必須將先前步驟中建立的 API 金鑰提供給 Maps SDK for iOS,才能將帳戶與應用程式中顯示的地圖建立關聯。
如要提供 API 金鑰,請開啟 AppDelegate.swift
檔案,然後前往 application(_, didFinishLaunchingWithOptions)
方法。SDK 是使用 GMSServices.provideAPIKey()
和「YOUR_API_KEY」字串初始化。然後將該字串替換成您的 API 金鑰。完成這個步驟後,應用程式啟動時就會初始化 Maps SDK for iOS。
使用 MapViewControllerBridge 新增 Google 地圖
現在 API 金鑰已提供給 SDK,下一步是在應用程式中顯示地圖。
範例程式碼中提供的檢視區塊控制器 MapViewController
,會在檢視區塊中包含 GMSMapView
。不過,由於這個檢視控制器是在 UIKit 中建立,因此您需要將這個類別橋接至 SwiftUI,才能在 ContentView
中使用。方法如下:
- 在 Xcode 中開啟
MapViewControllerBridge
檔案。
這個類別符合 UIViewControllerRepresentable,這是封裝 UIKit UIViewController
時所需的通訊協定,因此可以做為 SwiftUI 檢視區塊使用。換句話說,只要符合這項通訊協定,就能輕鬆將 UIKit 檢視區塊橋接至 SwiftUI 檢視區塊。如要遵守這項通訊協定,必須實作兩種方法:
makeUIViewController(context)
- SwiftUI 會呼叫這個方法,建立基礎UIViewController
。您可以在這裡例項化UIViewController
,並傳遞初始狀態。updateUIViewController(_, context)
:每當狀態變更時,SwiftUI 就會呼叫這個方法。您可以在這裡修改基礎UIViewController
,以回應狀態變更。
- 建立
MapViewController
在 makeUIViewController(context)
函式中,例項化新的 MapViewController
,並以結果形式傳回。完成後,您的 MapViewControllerBridge
應如下所示:
MapViewControllerBridge
import GoogleMaps
import SwiftUI
struct MapViewControllerBridge: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> MapViewController {
return MapViewController()
}
func updateUIViewController(_ uiViewController: MapViewController, context: Context) {
}
}
在 ContentView 中使用 MapViewControllerBridge
MapViewControllerBridge
現在會建立 MapViewController
的執行個體,下一個步驟是在 ContentView
中使用這個結構體來顯示地圖。
- 在 Xcode 中開啟
ContentView
檔案。
ContentView
會在 SceneDelegate
中例項化,並包含頂層應用程式檢視畫面。地圖會從這個檔案新增。
- 在
body
屬性中建立MapViewControllerBridge
。
這個檔案的 body
屬性中已提供並實作 ZStack
。ZStack
包含可互動及可拖曳的城市清單,您會在後續步驟中使用。目前,請在 ZStack
內建立 MapViewControllerBridge
,做為 ZStack
的第一個子項檢視區塊,這樣應用程式就會在城市清單檢視區塊後方顯示地圖。完成後,ContentView
中的 body
屬性內容應如下所示:
ContentView
var body: some View {
let scrollViewHeight: CGFloat = 80
GeometryReader { geometry in
ZStack(alignment: .top) {
// Map
MapViewControllerBridge()
// Cities List
CitiesList(markers: $markers) { (marker) in
guard self.selectedMarker != marker else { return }
self.selectedMarker = marker
self.zoomInCenter = false
self.expandList = false
} handleAction: {
self.expandList.toggle()
} // ...
}
}
}
- 現在請執行應用程式。您應該會在裝置畫面上看到載入的地圖,以及畫面底部的可拖曳城市清單。
7. 在地圖中加入標記
在上一個步驟中,您新增了地圖,以及顯示城市清單的可互動清單。在本節中,您將為清單中的每個城市新增標記。
標記做為狀態
宣告名為 markers
的屬性,這個屬性是 GMSMarker
清單,代表 cities
靜態屬性中宣告的每個城市。ContentView
請注意,這個屬性會使用 SwiftUI 屬性包裝函式 State 加上註解,表示應由 SwiftUI 管理。因此,如果系統偵測到這項屬性有任何變更 (例如新增或移除標記),使用這項狀態的檢視畫面就會更新。
ContentView
static let cities = [
City(name: "San Francisco", coordinate: CLLocationCoordinate2D(latitude: 37.7576, longitude: -122.4194)),
City(name: "Seattle", coordinate: CLLocationCoordinate2D(latitude: 47.6131742, longitude: -122.4824903)),
City(name: "Singapore", coordinate: CLLocationCoordinate2D(latitude: 1.3440852, longitude: 103.6836164)),
City(name: "Sydney", coordinate: CLLocationCoordinate2D(latitude: -33.8473552, longitude: 150.6511076)),
City(name: "Tokyo", coordinate: CLLocationCoordinate2D(latitude: 35.6684411, longitude: 139.6004407))
]
/// State for markers displayed on the map for each city in `cities`
@State var markers: [GMSMarker] = cities.map {
let marker = GMSMarker(position: $0.coordinate)
marker.title = $0.name
return marker
}
請注意,ContentView
會使用 markers
屬性,將城市清單傳遞至 CitiesList
類別,藉此算繪清單。
CitiesList
struct CitiesList: View {
@Binding var markers: [GMSMarker]
var body: some View {
GeometryReader { geometry in
VStack(spacing: 0) {
// ...
// List of Cities
List {
ForEach(0..<self.markers.count) { id in
let marker = self.markers[id]
Button(action: {
buttonAction(marker)
}) {
Text(marker.title ?? "")
}
}
}.frame(maxWidth: .infinity)
}
}
}
}
使用 @Binding
將 State 傳遞至 MapViewControllerBridge
除了顯示 markers
屬性資料的城市清單外,請將這個屬性傳遞至 MapViewControllerBridge
結構體,以便在 Google 地圖上顯示這些標記。請按照下列步驟操作:
- 在
MapViewControllerBridge
中宣告以@Binding
註解的新markers
屬性
MapViewControllerBridge
struct MapViewControllerBridge: : UIViewControllerRepresentable {
@Binding var markers: [GMSMarker]
// ...
}
- 在
MapViewControllerBridge
中,更新updateUIViewController(_, context)
方法以使用markers
屬性
如上一步所述,每當狀態變更時,SwiftUI 就會呼叫 updateUIViewController(_, context)
。我們想在這個方法中更新地圖,以便在 markers
中顯示標記。如要這麼做,請更新每個標記的 map
屬性。完成這個步驟後,MapViewControllerBridge
應如下所示:
import GoogleMaps
import SwiftUI
struct MapViewControllerBridge: UIViewControllerRepresentable {
@Binding var markers: [GMSMarker]
func makeUIViewController(context: Context) -> MapViewController {
return MapViewController()
}
func updateUIViewController(_ uiViewController: MapViewController, context: Context) {
// Update the map for each marker
markers.forEach { $0.map = uiViewController.map }
}
}
- 將
ContentView
的markers
屬性傳遞至MapViewControllerBridge
由於您在 MapViewControllerBridge
中新增了屬性,因此現在必須在 MapViewControllerBridge
的初始值中傳遞這個屬性的值。因此,如果您嘗試建構應用程式,應該會發現應用程式無法編譯。如要修正這個問題,請更新 ContentView
,建立 MapViewControllerBridge
並傳入 markers
屬性,如下所示:
struct ContentView: View {
// ...
var body: some View {
// ...
GeometryReader { geometry in
ZStack(alignment: .top) {
// Map
MapViewControllerBridge(markers: $markers)
// ...
}
}
}
}
請注意,由於 MapViewControllerBridge
預期會收到繫結屬性,因此使用前置字串 $
將 markers
傳遞至 MapViewControllerBridge
。$
是預留的前置字元,用於 Swift 屬性包裝函式。如果套用至 State,則會傳回 Binding。
- 請執行應用程式,查看地圖上顯示的標記。
8. 為所選城市製作動畫
在上一個步驟中,您已將「狀態」從一個 SwiftUI 檢視區塊傳遞至另一個檢視區塊,在地圖上新增標記。在這個步驟中,您會在互動式清單中輕觸城市或標記後,將動畫效果套用至該城市或標記。如要執行動畫,您必須在發生變更時修改地圖的攝影機位置,藉此對 State 的變更做出反應。如要進一步瞭解地圖攝影機的概念,請參閱「攝影機和檢視畫面」。
將地圖動畫效果套用至所選城市
如要將地圖動畫效果套用至所選城市,請按照下列步驟操作:
- 在
MapViewControllerBridge
中定義新的繫結
ContentView
具有名為 selectedMarker
的 State 屬性,該屬性會初始化為 nil,並在清單中選取城市時更新。這項作業是由 ContentView
內的 CitiesList
檢視區塊 buttonAction
處理。
ContentView
CitiesList(markers: $markers) { (marker) in
guard self.selectedMarker != marker else { return }
self.selectedMarker = marker
// ...
}
每當 selectedMarker
變更時,MapViewControllerBridge
應留意這項狀態變更,以便將地圖動畫設為所選標記。因此,請在 MapViewControllerBridge
中定義 GMSMarker
類型的全新繫結,並將屬性命名為 selectedMarker
。
MapViewControllerBridge
struct MapViewControllerBridge: UIViewControllerRepresentable {
@Binding var selectedMarker: GMSMarker?
}
- 更新
MapViewControllerBridge
,在selectedMarker
變更時為地圖加上動畫效果
宣告新的繫結後,您需要更新 MapViewControllerBridge
的 updateUIViewController_, context)
函式,讓地圖動畫顯示所選標記。請複製下列程式碼,然後執行這項操作:
struct MapViewControllerBridge: UIViewControllerRepresentable {
@Binding var selectedMarker: GMSMarker?
func updateUIViewController(_ uiViewController: MapViewController, context: Context) {
markers.forEach { $0.map = uiViewController.map }
selectedMarker?.map = uiViewController.map
animateToSelectedMarker(viewController: uiViewController)
}
private func animateToSelectedMarker(viewController: MapViewController) {
guard let selectedMarker = selectedMarker else {
return
}
let map = viewController.map
if map.selectedMarker != selectedMarker {
map.selectedMarker = selectedMarker
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
map.animate(toZoom: kGMSMinZoomLevel)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
map.animate(with: GMSCameraUpdate.setTarget(selectedMarker.position))
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: {
map.animate(toZoom: 12)
})
}
}
}
}
}
animateToSelectedMarker(viewController)
函式會使用 GMSMapView
的 animate(with)
函式執行一連串的地圖動畫。
- 將
ContentView
的selectedMarker
傳遞至MapViewControllerBridge
MapViewControllerBridge
宣告新的繫結後,請繼續更新 ContentView
,在 MapViewControllerBridge
例項化的位置傳遞 selectedMarker
。
ContentView
struct ContentView: View {
// ...
var body: some View {
// ...
GeometryReader { geometry in
ZStack(alignment: .top) {
// Map
MapViewControllerBridge(markers: $markers, selectedMarker: $selectedMarker)
// ...
}
}
}
}
完成這個步驟後,每當在清單中選取新城市,地圖就會顯示動畫。
以動畫呈現 SwiftUI 檢視畫面,強調城市
SwiftUI 會處理狀態轉換的動畫,因此簡化了檢視區塊動畫的程序。為說明這點,您將在完成地圖動畫後,將檢視畫面聚焦於所選城市,藉此新增更多動畫。如要完成這項作業,請按照下列步驟操作:
- 在
MapViewControllerBridge
中新增onAnimationEnded
閉包
由於 SwiftUI 動畫會在您先前新增的地圖動畫序列後執行,因此請在 MapViewControllerBridge
中宣告名為 onAnimationEnded
的新閉包,並在 animateToSelectedMarker(viewController)
方法中,於最後一個地圖動畫後延遲 0.5 秒再叫用這個閉包。
MapViewControllerBridge
struct MapViewControllerBridge: UIViewControllerRepresentable {
var onAnimationEnded: () -> ()
private func animateToSelectedMarker(viewController: MapViewController) {
guard let selectedMarker = selectedMarker else {
return
}
let map = viewController.map
if map.selectedMarker != selectedMarker {
map.selectedMarker = selectedMarker
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
map.animate(toZoom: kGMSMinZoomLevel)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
map.animate(with: GMSCameraUpdate.setTarget(selectedMarker.position))
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: {
map.animate(toZoom: 12)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: {
// Invoke onAnimationEnded() once the animation sequence completes
onAnimationEnded()
})
})
}
}
}
}
}
- 在
MapViewControllerBridge
中導入onAnimationEnded
實作 onAnimationEnded
閉包,在 ContentView
中執行個體化 MapViewControllerBridge
。複製並貼上下列程式碼,新增名為 zoomInCenter
的 State,並使用 clipShape
修改檢視區塊,根據 zoomInCenter
的值調整裁剪形狀的直徑
ContentView
struct ContentView: View {
@State var zoomInCenter: Bool = false
// ...
var body: some View {
// ...
GeometryReader { geometry in
ZStack(alignment: .top) {
// Map
let diameter = zoomInCenter ? geometry.size.width : (geometry.size.height * 2)
MapViewControllerBridge(markers: $markers, selectedMarker: $selectedMarker, onAnimationEnded: {
self.zoomInCenter = true
})
.clipShape(
Circle()
.size(
width: diameter,
height: diameter
)
.offset(
CGPoint(
x: (geometry.size.width - diameter) / 2,
y: (geometry.size.height - diameter) / 2
)
)
)
.animation(.easeIn)
.background(Color(red: 254.0/255.0, green: 1, blue: 220.0/255.0))
}
}
}
}
- 請執行應用程式,查看動畫效果!
9. 將事件傳送至 SwiftUI
在這個步驟中,您會監聽 GMSMapView
發出的事件,並將該事件傳送至 SwiftUI。具體來說,您會為地圖檢視區塊設定委派,並監聽攝影機移動事件,這樣一來,當您聚焦於某個城市,並透過手勢移動地圖攝影機時,地圖檢視區塊就會取消聚焦,讓您看到更多地圖內容。
使用 SwiftUI 協調器
GMSMapView
會發出攝影機位置變更或輕觸標記等事件。如要監聽這些事件,請使用 GMSMapViewDelegate 通訊協定。SwiftUI 導入了「協調器」的概念,專門用來做為 UIKit 檢視區塊控制器的委派。因此在 SwiftUI 世界中,協調器應負責遵守 GMSMapViewDelegate
通訊協定。如要這樣做,請按照下列步驟進行:
- 在
MapViewControllerBridge
中建立名為MapViewCoordinator
的協調員
在 MapViewControllerBridge
類別中建立巢狀類別,並命名為 MapViewCoordinator
。這個類別應符合 GMSMapViewDelegate
,並將 MapViewControllerBridge
宣告為屬性。
MapViewControllerBridge
struct MapViewControllerBridge: UIViewControllerRepresentable {
// ...
final class MapViewCoordinator: NSObject, GMSMapViewDelegate {
var mapViewControllerBridge: MapViewControllerBridge
init(_ mapViewControllerBridge: MapViewControllerBridge) {
self.mapViewControllerBridge = mapViewControllerBridge
}
}
}
- 在
MapViewControllerBridge
中導入makeCoordinator()
接著,在 MapViewControllerBridge
中實作 makeCoordinator()
方法,並傳回您在上一個步驟中建立的 MapViewCoodinator
執行個體。
MapViewControllerBridge
struct MapViewControllerBridge: UIViewControllerRepresentable {
// ...
func makeCoordinator() -> MapViewCoordinator {
return MapViewCoordinator(self)
}
}
- 將
MapViewCoordinator
設為地圖檢視區塊的委派項目
建立自訂協調器後,下一步是將協調器設為檢視畫面控制器的地圖檢視畫面委派。如要這麼做,請更新 makeUIViewController(context)
中的檢視區塊控制器初始化作業。您可透過 Context 物件存取上一個步驟中建立的協調器。
MapViewControllerBridge
struct MapViewControllerBridge: UIViewControllerRepresentable {
// ...
func makeUIViewController(context: Context) -> MapViewController {
let uiViewController = MapViewController()
uiViewController.map.delegate = context.coordinator
return uiViewController
}
}
- 在
MapViewControllerBridge
中新增閉包,攝影機移動事件即可向上傳播
由於目標是使用攝影機移動更新檢視區塊,因此請在 MapViewControllerBridge
中宣告新的閉包屬性,接受名為 mapViewWillMove
的布林值,並在 MapViewCoordinator
內的委派方法 mapView(_, willMove)
中叫用這個閉包。將 gesture
的值傳遞至閉包,這樣 SwiftUI 檢視區塊就只會對手勢相關的攝影機移動事件做出反應。
MapViewControllerBridge
struct MapViewControllerBridge: UIViewControllerRepresentable {
var mapViewWillMove: (Bool) -> ()
//...
final class MapViewCoordinator: NSObject, GMSMapViewDelegate {
// ...
func mapView(_ mapView: GMSMapView, willMove gesture: Bool) {
self.mapViewControllerBridge.mapViewWillMove(gesture)
}
}
}
- 更新 ContentView,傳遞
mapWillMove
的值
由於 MapViewControllerBridge
宣告了新的結尾,請更新 ContentView
,傳遞這個新結尾的值。在該閉包中,如果移動事件與手勢相關,請將「State」zoomInCenter
切換為 false
。這樣一來,地圖透過手勢移動後,就會再次以全螢幕顯示。
ContentView
struct ContentView: View {
@State var zoomInCenter: Bool = false
// ...
var body: some View {
// ...
GeometryReader { geometry in
ZStack(alignment: .top) {
// Map
let diameter = zoomInCenter ? geometry.size.width : (geometry.size.height * 2)
MapViewControllerBridge(markers: $markers, selectedMarker: $selectedMarker, onAnimationEnded: {
self.zoomInCenter = true
}, mapViewWillMove: { (isGesture) in
guard isGesture else { return }
self.zoomInCenter = false
})
// ...
}
}
}
}
- 請執行應用程式,查看新的變更!
10. 恭喜
恭喜你完成目前為止的工作!您已學到許多內容,希望這些課程能幫助您使用 Maps SDK for iOS 建構自己的 SwiftUI 應用程式。
您學到的內容
- SwiftUI 和 UIKit 的差異
- 如何使用 UIViewControllerRepresentable 在 SwiftUI 和 UIKit 之間建立橋接器
- 如何使用 State 和 Binding 變更地圖檢視畫面
- 如何使用 Coordinator 將事件從地圖檢視區塊傳送至 SwiftUI
後續步驟
- Maps SDK for iOS
- Maps SDK for iOS 的官方說明文件
- Places SDK for iOS:尋找您附近的當地商家和搜尋點
- maps-sdk-for-ios-samples
- GitHub 上的程式碼範例,示範 Maps SDK for iOS 的所有功能。
- SwiftUI - Apple 官方的 SwiftUI 說明文件
- 請填寫下列問卷調查,協助我們製作最實用的內容:
你還想看到哪些程式碼研究室?
找不到最感興趣的程式碼研究室嗎?請在這裡提出新的問題。