前言
作为公司唯一的移动端开发,我需要同时负责 5 个 Flutter App 的开发和维护工作。每个应用都需要支持 iOS 和 Android 双平台,这意味着每次发版我可能要进行多达 10 次的打包操作。如果每次都手动执行构建、上传、通知这些重复性工作,不仅耗时巨大,还极易出错。
为了从繁琐的重复劳动中解放出来,把更多精力投入到真正有价值的开发工作中,我使用 Python 编写了一套自动化脚本,实现了一键完成构建、上传和通知的完整流程。
本文将分享我在这个过程中的实践经验和代码实现。
项目背景
公司目前有 5 个移动端应用在同时运营,而移动端开发只有我一个人。每个应用都是基于 Flutter 开发的跨平台应用,需要同时支持 iOS 和 Android 平台。在日常开发中,存在以下痛点:
-
项目多、人手少:5 个 App × 2 个平台 = 10 个构建任务,一个人根本忙不过来
-
构建流程繁琐:每次打包都需要手动执行多个命令,切换项目、切换环境配置
-
上传步骤重复:构建完成后需要手动上传到测试平台(蒲公英),操作机械且耗时
-
通知不及时:需要手动通知测试人员新版本已就绪,容易遗漏
-
iOS 构建环境问题:CocoaPods 缓存问题经常导致构建失败,排查费时费力
面对这样的工作强度,自动化不再是"锦上添花",而是"刚需"。
技术方案
我设计了以下几个 Python 脚本来解决这些问题:
python/
├── build_app.py # 主构建脚本(iOS + Android)
├── build_android_app.py # Android 单独构建脚本
├── clean_ios_build.py # iOS 构建环境清理
├── force_clean_ios.py # 强制清理脚本
├── bulk_email.py # 群发邮件工具类
├── send_email.py # 单封邮件发送
└── test_email_auth.py # 邮箱授权测试
核心实现
1. 自动构建脚本
构建脚本的核心功能是自动执行 Flutter 构建命令,并支持不同环境(开发/生产)的配置。
#!/usr/local/bin/python3
import os
import subprocess
# 获取当前脚本所在目录
script_dir = os.path.dirname(os.path.abspath(__file__))
# Flutter项目根目录(python文件夹在项目根目录下)
flutter_root = os.path.dirname(script_dir)
# 获取用户输入的环境
env = input("请输入环境(dev/prod): ")
# 检查环境配置文件是否存在
env_file = os.path.join(flutter_root, f"{env}.json")
if not os.path.exists(env_file):
print(f"错误: 环境配置文件 {env}.json 不存在")
exit(1)
# 切换到Flutter项目根目录
os.chdir(flutter_root)
# 构建Android应用
def build_android(env):
print("正在构建Android应用...")
env_text = '生产' if env == 'prod' else '开发'
print(f"构建版本: {env_text}环境...")
# 构建命令,支持代码混淆
build_command = f'fvm flutter build apk --release --dart-define-from-file={env}.json --obfuscate --split-debug-info=./build/debug_info'
try:
process = subprocess.run(
build_command.split(),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True
)
print("构建输出:")
print(process.stdout)
print("Android构建成功!")
print("APK文件路径: build/app/outputs/flutter-apk/app-release.apk")
return True
except subprocess.CalledProcessError as e:
print(f"Android构建失败: {e}")
return False
# 构建iOS应用
def build_ios(env, upload_to_appstore=False):
print("正在构建iOS应用...")
env_text = '生产' if env == 'prod' else '开发'
# 根据是否上传App Store选择导出方法
export_method = "app-store" if upload_to_appstore else "development"
build_command = f"fvm flutter build ipa --release --export-method {export_method} --dart-define-from-file={env}.json --obfuscate --split-debug-info=./build/debug_info"
try:
process = subprocess.run(
build_command.split(),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True
)
print("构建输出:")
print(process.stdout)
print("iOS构建成功!")
return True
except subprocess.CalledProcessError as e:
print(f"iOS构建失败: {e}")
return False
2. 自动上传到蒲公英
构建完成后,自动将安装包上传到蒲公英测试平台:
import requests
def upload_to_pgyer(env, ipa_path, platform):
"""上传到蒲公英测试平台"""
print(f"正在上传到蒲公英...")
print(f"文件路径: {ipa_path}")
# 从配置文件或环境变量读取 API Key
api_key = os.environ.get('PGYER_API_KEY', 'your_api_key')
user_key = os.environ.get('PGYER_USER_KEY', 'your_user_key')
files = {"file": open(ipa_path, "rb")}
headers = {"enctype": "multipart/form-data"}
platform_text = "android" if platform == "android" else "ios"
payload = {
"uKey": user_key,
"_api_key": api_key,
"installType": 1,
"updateDescription": f"{platform_text}自动化打包"
}
try:
response = requests.post(
"https://www.pgyer.com/apiv2/app/upload",
data=payload,
files=files,
headers=headers
)
result = response.json()
# 获取构建信息
qr_code_url = result["data"]["buildQRCodeURL"]
version = result["data"]["buildVersion"]
version_no = result["data"]["buildVersionNo"]
build_name = result["data"]["buildName"]
print(f"上传成功!")
print(f"二维码地址: {qr_code_url}")
print(f"版本: {version} ({version_no})")
return {
"qr_code_url": qr_code_url,
"version": version,
"version_no": version_no,
"build_name": build_name
}
except Exception as e:
print(f"上传失败: {e}")
return None
3. 群发邮件通知
构建并上传成功后,自动发送邮件通知团队成员:
#!/usr/local/bin/python3
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.mime.base import MIMEBase
from email import encoders
import os
import time
from typing import List, Dict, Optional
class BulkEmailSender:
"""群发邮件发送器"""
def __init__(self, smtp_server: str, smtp_port: int,
sender_email: str, sender_password: str):
"""
初始化群发邮件发送器
Args:
smtp_server: SMTP服务器地址
smtp_port: SMTP端口
sender_email: 发送者邮箱
sender_password: 发送者邮箱密码或授权码
"""
self.smtp_server = smtp_server
self.smtp_port = smtp_port
self.sender_email = sender_email
self.sender_password = sender_password
def _create_connection(self):
"""创建SMTP连接"""
try:
if self.smtp_port == 465:
# 使用SSL连接(465端口)
server = smtplib.SMTP_SSL(self.smtp_server, self.smtp_port)
else:
# 使用STARTTLS连接(587/25端口)
server = smtplib.SMTP(self.smtp_server, self.smtp_port)
server.starttls()
server.login(self.sender_email, self.sender_password)
return server
except Exception as e:
print(f"连接失败: {e}")
return None
def send_bulk_individual(self, recipients: List[str], subject: str,
body: str, html_body: Optional[str] = None,
attachment_path: Optional[str] = None,
delay: float = 1.0) -> Dict[str, bool]:
"""
逐个发送邮件(隐私保护最好,每个人只能看到自己的邮箱)
Args:
recipients: 收件人列表
subject: 邮件主题
body: 邮件内容(纯文本)
html_body: HTML邮件内容(可选)
attachment_path: 附件路径(可选)
delay: 发送间隔(秒),避免被服务器限制
"""
results = {}
server = self._create_connection()
if not server:
return {email: False for email in recipients}
try:
for i, recipient in enumerate(recipients):
try:
print(f"发送邮件 {i+1}/{len(recipients)} 到: {recipient}")
# 创建邮件
if html_body:
msg = MIMEMultipart('alternative')
msg.attach(MIMEText(body, 'plain', 'utf-8'))
msg.attach(MIMEText(html_body, 'html', 'utf-8'))
else:
msg = MIMEMultipart()
msg.attach(MIMEText(body, 'plain', 'utf-8'))
msg['From'] = self.sender_email
msg['To'] = recipient
msg['Subject'] = subject
# 添加附件
if attachment_path and os.path.exists(attachment_path):
self._add_attachment(msg, attachment_path)
# 发送邮件
server.sendmail(self.sender_email, [recipient], msg.as_string())
results[recipient] = True
print(f"✅ 发送成功: {recipient}")
# 延迟避免被限制
if i < len(recipients) - 1:
time.sleep(delay)
except Exception as e:
results[recipient] = False
print(f"❌ 发送失败 {recipient}: {e}")
finally:
server.quit()
return results
def send_bulk_bcc(self, recipients: List[str], subject: str, body: str,
html_body: Optional[str] = None, batch_size: int = 50) -> bool:
"""
使用BCC批量发送(隐私保护,收件人看不到其他人)
适合大批量发送通知邮件
"""
server = self._create_connection()
if not server:
return False
try:
# 分批发送,避免单次发送太多
for i in range(0, len(recipients), batch_size):
batch = recipients[i:i + batch_size]
print(f"发送批次 {i//batch_size + 1}: {len(batch)} 个收件人")
if html_body:
msg = MIMEMultipart('alternative')
msg.attach(MIMEText(body, 'plain', 'utf-8'))
msg.attach(MIMEText(html_body, 'html', 'utf-8'))
else:
msg = MIMEMultipart()
msg.attach(MIMEText(body, 'plain', 'utf-8'))
msg['From'] = self.sender_email
msg['To'] = self.sender_email # 显示发送者自己
msg['Bcc'] = ', '.join(batch) # 密送给所有收件人
msg['Subject'] = subject
all_recipients = [self.sender_email] + batch
server.sendmail(self.sender_email, all_recipients, msg.as_string())
print(f"✅ 批次发送成功: {len(batch)} 个收件人")
if i + batch_size < len(recipients):
time.sleep(2)
return True
except Exception as e:
print(f"❌ BCC群发失败: {e}")
return False
finally:
server.quit()
def _add_attachment(self, msg: MIMEMultipart, attachment_path: str):
"""添加附件到邮件"""
with open(attachment_path, "rb") as attachment:
part = MIMEBase('application', 'octet-stream')
part.set_payload(attachment.read())
encoders.encode_base64(part)
part.add_header(
'Content-Disposition',
f'attachment; filename= {os.path.basename(attachment_path)}'
)
msg.attach(part)
4. 发送构建通知邮件
将构建信息通过 HTML 邮件发送给团队:
def send_build_notification(build_info, env, platform, test_content=""):
"""发送构建通知邮件"""
env_text = '生产' if env == 'prod' else '开发'
platform_text = "Android" if platform == "android" else "iOS"
# 构建HTML邮件内容
html_body = f"""
<html>
<body>
<h2>项目构建通知</h2>
<p>构建状态: <span style="color: green;"><b>成功</b></span></p>
<ul>
<li>构建名称: {build_info['build_name']}</li>
<li>平台: {platform_text}</li>
<li>环境: {env_text}</li>
<li>版本: {build_info['version']}</li>
<li>版本号: {build_info['version_no']}</li>
<li>测试内容: {test_content}</li>
</ul>
<img src="{build_info['qr_code_url']}" alt="下载二维码">
<p>请扫描二维码下载安装测试。</p>
</body>
</html>
"""
# 从环境变量读取邮件配置
smtp_server = os.environ.get('SMTP_SERVER', 'smtp.exmail.qq.com')
smtp_port = int(os.environ.get('SMTP_PORT', '587'))
sender_email = os.environ.get('SENDER_EMAIL')
sender_password = os.environ.get('SENDER_PASSWORD')
bulk_sender = BulkEmailSender(
smtp_server=smtp_server,
smtp_port=smtp_port,
sender_email=sender_email,
sender_password=sender_password
)
# 收件人列表(从配置文件读取)
recipients = load_recipients_from_config()
subject = f"构建通知: {build_info['build_name']} - {platform_text} - {env_text}环境"
results = bulk_sender.send_bulk_individual(
recipients=recipients,
subject=subject,
body=f'{platform_text}打包通知',
html_body=html_body,
delay=0.5
)
return results
5. iOS 构建环境清理脚本
在 iOS 开发中,经常会遇到 CocoaPods 缓存导致的构建问题。这个脚本可以彻底清理构建环境:
#!/usr/bin/env python3
"""
iOS 构建环境清理脚本
用于解决 Firebase Crashlytics 模块化头文件等常见问题
"""
import os
import subprocess
import shutil
def run_command(command, description):
"""执行命令并打印结果"""
print(f"\n{description}...")
print(f"执行命令: {command}")
try:
result = subprocess.run(command, shell=True, capture_output=True, text=True)
if result.stdout:
print("输出:", result.stdout)
if result.returncode == 0:
print(f"✅ {description} 成功")
else:
print(f"❌ {description} 失败")
return result.returncode == 0
except Exception as e:
print(f"❌ {description} 异常: {e}")
return False
def force_clean_ios():
"""强制清理 iOS 构建环境"""
print("🧹 开始强制清理 iOS 构建环境...")
# 获取项目根目录
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ios_dir = os.path.join(project_root, "ios")
if not os.path.exists(ios_dir):
print(f"❌ iOS 目录不存在: {ios_dir}")
return False
os.chdir(project_root)
print(f"📁 当前工作目录: {os.getcwd()}")
# 1. 清理 Flutter 缓存
run_command("fvm flutter clean", "清理 Flutter 构建缓存")
# 2. 删除 pubspec.lock
pubspec_lock = os.path.join(project_root, "pubspec.lock")
if os.path.exists(pubspec_lock):
print(f"🗑️ 删除 pubspec.lock")
os.remove(pubspec_lock)
# 3. 删除 .dart_tool 目录
dart_tool_dir = os.path.join(project_root, ".dart_tool")
if os.path.exists(dart_tool_dir):
print(f"🗑️ 删除 .dart_tool 目录")
shutil.rmtree(dart_tool_dir)
# 4. 删除 iOS 构建目录
for dir_name in ["build", "Pods", ".symlinks"]:
dir_path = os.path.join(ios_dir, dir_name)
if os.path.exists(dir_path):
print(f"🗑️ 删除 {dir_name} 目录")
shutil.rmtree(dir_path)
# 5. 删除 Podfile.lock
podfile_lock = os.path.join(ios_dir, "Podfile.lock")
if os.path.exists(podfile_lock):
print(f"🗑️ 删除 Podfile.lock")
os.remove(podfile_lock)
# 6. 清理 CocoaPods 缓存
run_command("pod cache clean --all", "清理 CocoaPods 缓存")
# 7. 重新获取 Flutter 依赖
run_command("fvm flutter pub get", "重新获取 Flutter 依赖")
# 8. 重新安装 Pods
os.chdir(ios_dir)
run_command("pod install --repo-update", "重新安装 Pods")
print("\n🎉 强制清理完成!")
print("💡 现在可以重新尝试构建 iOS 应用了")
return True
if __name__ == "__main__":
force_clean_ios()
6. 邮箱授权测试工具
在配置邮件服务前,可以使用这个工具测试授权码是否正确:
#!/usr/local/bin/python3
import smtplib
def test_email_auth(smtp_server, smtp_port, email, auth_code):
"""
测试邮箱授权码是否正确
"""
try:
print(f"正在测试邮箱: {email}")
print(f"SMTP服务器: {smtp_server}:{smtp_port}")
# 连接SMTP服务器
server = smtplib.SMTP(smtp_server, smtp_port)
server.starttls()
# 尝试登录
server.login(email, auth_code)
server.quit()
print("✅ 授权码验证成功!")
return True
except smtplib.SMTPAuthenticationError:
print("❌ 授权码验证失败!请检查:")
print(" 1. 授权码是否正确")
print(" 2. 是否已开启SMTP服务")
print(" 3. 是否使用了邮箱密码而非授权码")
return False
except Exception as e:
print(f"❌ 连接失败: {e}")
return False
if __name__ == "__main__":
# 常用邮箱SMTP配置
email_configs = {
'qq': ('smtp.qq.com', 587),
'163': ('smtp.163.com', 25),
'gmail': ('smtp.gmail.com', 587),
'outlook': ('smtp-mail.outlook.com', 587),
'wechat': ('smtp.exmail.qq.com', 587)
}
print("=== 邮箱授权码测试工具 ===\n")
email_type = input("请选择邮箱类型 (qq/163/gmail/outlook/wechat): ").lower()
if email_type not in email_configs:
print("不支持的邮箱类型")
exit(1)
email = input("请输入邮箱地址: ")
auth_code = input("请输入授权码: ")
smtp_server, smtp_port = email_configs[email_type]
test_email_auth(smtp_server, smtp_port, email, auth_code)
使用方式
1. 环境准备
首先确保安装了必要的 Python 依赖:
pip install requests
2. 配置敏感信息
建议使用环境变量或配置文件管理敏感信息,不要硬编码在脚本中:
# 设置环境变量
export PGYER_API_KEY="your_api_key"
export PGYER_USER_KEY="your_user_key"
export SENDER_EMAIL="your_email@example.com"
export SENDER_PASSWORD="your_auth_code"
export SMTP_SERVER="smtp.exmail.qq.com"
export SMTP_PORT="587"
3. 执行构建
# 进入 python 脚本目录
cd python
# 执行构建脚本
python3 build_app.py
脚本会依次提示:
- 选择环境(dev/prod)
- 是否发送邮件通知
- 输入测试内容
- 是否上传到 App Store(仅生产环境)
4. 清理 iOS 构建环境
当遇到 iOS 构建问题时,执行:
python3 force_clean_ios.py
最佳实践
1. 敏感信息管理
- 使用环境变量存储 API Key、密码等敏感信息
- 不要将敏感信息提交到版本控制
- 可以使用
.env 文件配合 python-dotenv 库
2. 错误处理
- 每个关键步骤都添加 try-except 处理
- 构建失败时输出详细错误信息
- 记录日志便于问题排查
3. 邮件发送策略
- 群发邮件时添加适当延迟,避免被服务器限制
- 使用 BCC 方式保护收件人隐私
- 分批发送大量邮件
4. 构建优化
- 使用
--obfuscate 参数进行代码混淆
- 使用
--split-debug-info 分离调试信息
- 根据环境使用不同的配置文件
总结
通过 Python 脚本实现 Flutter 项目的自动化构建,可以显著提高开发效率:
-
一键完成:构建、上传、通知全流程自动化
-
减少出错:避免手动操作带来的失误
-
节省时间:构建期间可以专注于其他工作
-
规范流程:统一的构建和发布流程
这套脚本已经在我使用了一段时间,效果良好。希望这篇文章对有类似需求的开发者有所帮助。
相关技术栈:
- Python 3.x
- Flutter + FVM
- 蒲公英测试平台
- SMTP 邮件服务