Best practice for calling Apex methods from custom button?
There isn't really any problem with what you're doing. While you could set something up via Apex REST to support direct execution of methods without having an empty page, an empty page isn't a problem.
The one thing you do want to be aware of that whoever told you to avoid empty VF pages may have been referring to is that you can easily introduce a cross-site request forgery (CSRF) vulnerability if your page is doing something that an attacker would be interested in exploiting, like inserting/deleting records. This is something security reviewers care about, but frankly within a single organisation in an unmanaged package, it's usually a tiny risk. If this is a concern, the SFDC-recommended way to prevent this is to introduce a confirmation page in your VF where the user must click a button that triggers the action. All form posts in VF include a CSRF token which prevents CSRF attacks.
I declare the relevant Apex class as a webservice and then call it via a Javascript button.
The benefit of this approach is that you don't need to create an unneccessary VF page and you can do some nice alerts, data validation etc. in the Javascript before deciding whether or not to call the Apex method.
Sample JS button code:
{!requireScript("/soap/ajax/20.0/connection.js")}
{!requireScript("/soap/ajax/20.0/apex.js")}
var retStr;
retStr = sforce.apex.execute("ApexClassName", "MyWebServiceMethodName", {Id:'{!Account.Id}'});
alert('The method returned: ' + retStr);
document.location = '/{!Account.Id}';
Update as at Summer'13: New Feature: Require CSRF protection on GET requests
Since Summer'13 Salesforce have started what looks like a feature to start to address this. Through a checkbox on the Visualforce page metadata called '[Require CSRF protection on GET requests][1]'. When enabled it will ensure your page cannot be used via a GET request without a token generated by Salesforce. Currently only the Delete actions on custom objects support this, but its a start! I've made a request within Salesforce to understand the rollout of this feature, hopefully we will see it for all Custom Buttons soon!
Select Require CSRF protection on GET requests to enable Cross Site Request Forgery (CSRF) protection for GET requests for the page. When checked, it protects against CSRF attacks by modifying the page to require a CSRF confirmation token, a random string of characters in the URL parameters. With every GET request, Visualforce checks the validity of this string of characters and doesn’t load the page unless the value found matches the value expected. Check this box if the page performs any DML operation when it’s initially loaded. When checked, all links to this page need a CSRF token added to the URL query string parameters. This checkbox is available for pages set to API version 28.0 and later.
In Summer ’13, the only way to add a valid CSRF token to a URL is to override an object’s standard Delete link with a Visualforce page. The Delete link will automatically include the required token. Don’t check this box for any page that doesn’t override an object’s standard Delete link.